From da8a9a45913fe675ed27b2390f9be20c7d86f80e Mon Sep 17 00:00:00 2001
From: Sarah McAlear and Tira Odhner <pair+smcalear+aodhner@pivotal.io>
Date: Tue, 4 Apr 2017 11:24:44 -0400
Subject: [PATCH] Allow selecting columns in query output

- Add row, column, grid selector to SQLEditor
- Update README with xsel package to enable testing with Pyperclip on linux
- Refactor copying of data
- Deselect rows when clicking on column header
- Write a new row selection plugin to replace the problematic checkboxselectcolumn plugin
- the entire cell is clickable
- Extract the generic selection methods to rangeSelectionHelper
- just business logic and rendering logic are in row/column selectors.
---
 .../copy_selected_query_results_feature_test.py    |  67 ++++++
 web/pgadmin/static/js/selection/column_selector.js |  92 ++++++++
 web/pgadmin/static/js/selection/copy_data.js       |  42 ++++
 web/pgadmin/static/js/selection/grid_selector.js   |  79 +++++++
 .../js/selection/range_boundary_navigator.js       |  97 +++++++++
 .../static/js/selection/range_selection_helper.js  |  60 ++++++
 web/pgadmin/static/js/selection/row_selector.js    |  85 ++++++++
 .../tools/sqleditor/static/css/sqleditor.css       |   9 +
 .../sqleditor/templates/sqleditor/js/sqleditor.js  |  61 +-----
 web/regression/README                              |   6 +
 web/regression/feature_utils/base_feature_test.py  |   1 +
 web/regression/feature_utils/pgadmin_page.py       |  11 +
 .../javascript/selection/column_selector_spec.js   | 235 +++++++++++++++++++++
 .../javascript/selection/copy_data_spec.js         |  95 +++++++++
 .../javascript/selection/grid_selector_spec.js     | 126 +++++++++++
 .../selection/range_boundary_navigator_spec.js     | 139 ++++++++++++
 .../javascript/selection/row_selector_spec.js      | 174 +++++++++++++++
 web/regression/python_test_utils/test_utils.py     |   1 +
 web/regression/requirements.txt                    |   1 +
 19 files changed, 1329 insertions(+), 52 deletions(-)
 create mode 100644 web/pgadmin/feature_tests/copy_selected_query_results_feature_test.py
 create mode 100644 web/pgadmin/static/js/selection/column_selector.js
 create mode 100644 web/pgadmin/static/js/selection/copy_data.js
 create mode 100644 web/pgadmin/static/js/selection/grid_selector.js
 create mode 100644 web/pgadmin/static/js/selection/range_boundary_navigator.js
 create mode 100644 web/pgadmin/static/js/selection/range_selection_helper.js
 create mode 100644 web/pgadmin/static/js/selection/row_selector.js
 create mode 100644 web/regression/javascript/selection/column_selector_spec.js
 create mode 100644 web/regression/javascript/selection/copy_data_spec.js
 create mode 100644 web/regression/javascript/selection/grid_selector_spec.js
 create mode 100644 web/regression/javascript/selection/range_boundary_navigator_spec.js
 create mode 100644 web/regression/javascript/selection/row_selector_spec.js

diff --git a/web/pgadmin/feature_tests/copy_selected_query_results_feature_test.py b/web/pgadmin/feature_tests/copy_selected_query_results_feature_test.py
new file mode 100644
index 00000000..2f0bd336
--- /dev/null
+++ b/web/pgadmin/feature_tests/copy_selected_query_results_feature_test.py
@@ -0,0 +1,67 @@
+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 CopySelectedQueryResultsFeatureTest(BaseFeatureTest):
+    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.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)
+        ActionChains(self.page.driver).send_keys("SELECT * FROM test_table").perform()
+        self.page.driver.switch_to_frame(self.page.driver.find_element_by_tag_name("iframe"))
+        self.page.find_by_id("btn-flash").click()
+
+        self._copies_rows()
+        self._copies_columns()
+
+    def _copies_rows(self):
+        pyperclip.copy("old clipboard contents")
+        time.sleep(5)
+        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 _copies_columns(self):
+        pyperclip.copy("old clipboard contents")
+
+        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 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/static/js/selection/column_selector.js b/web/pgadmin/static/js/selection/column_selector.js
new file mode 100644
index 00000000..c89b3fa8
--- /dev/null
+++ b/web/pgadmin/static/js/selection/column_selector.js
@@ -0,0 +1,92 @@
+define(['jquery', 'sources/selection/range_selection_helper', 'slickgrid'], function ($, rangeSelectionHelper) {
+  var ColumnSelector = function () {
+    var init = function (grid) {
+      grid.onHeaderClick.subscribe(function (event, eventArgument) {
+          var column = eventArgument.column;
+
+          if (column.selectable !== false) {
+
+            if (!clickedCheckbox(event)) {
+              var $checkbox = $("[data-id='checkbox-" + column.id + "']");
+              toggleCheckbox($checkbox);
+            }
+
+            updateRanges(grid, column.id);
+          }
+        }
+      );
+      grid.getSelectionModel().onSelectedRangesChanged
+        .subscribe(handleSelectedRangesChanged.bind(null, grid));
+    };
+
+    var handleSelectedRangesChanged = function (grid, event, ranges) {
+      $('[data-cell-type="column-header-row"] input:checked')
+        .each(function (index, checkbox) {
+          var $checkbox = $(checkbox);
+          var columnIndex = grid.getColumnIndex($checkbox.data('column-id'));
+          var isStillSelected = rangeSelectionHelper.isRangeSelected(ranges, rangeSelectionHelper.rangeForColumn(grid, columnIndex));
+          if (!isStillSelected) {
+            toggleCheckbox($checkbox);
+          }
+        });
+    };
+
+    var updateRanges = function (grid, columnId) {
+      var selectionModel = grid.getSelectionModel();
+      var ranges = selectionModel.getSelectedRanges();
+
+      var columnIndex = grid.getColumnIndex(columnId);
+
+      var columnRange = rangeSelectionHelper.rangeForColumn(grid, columnIndex);
+      var newRanges;
+      if (rangeSelectionHelper.isRangeSelected(ranges, columnRange)) {
+        newRanges = rangeSelectionHelper.removeRange(ranges, columnRange);
+      } else {
+        if (rangeSelectionHelper.areAllRangesColumns(ranges, grid)) {
+          newRanges = rangeSelectionHelper.addRange(ranges, columnRange);
+        } else {
+          newRanges = [columnRange];
+        }
+      }
+      selectionModel.setSelectedRanges(newRanges);
+    };
+
+    var clickedCheckbox = function (e) {
+      return e.target.type == "checkbox"
+    };
+
+    var toggleCheckbox = function (checkbox) {
+      if (checkbox.prop("checked")) {
+        checkbox.prop("checked", false)
+      } else {
+        checkbox.prop("checked", true)
+      }
+    };
+
+    var getColumnDefinitionsWithCheckboxes = function (columnDefinitions) {
+      return _.map(columnDefinitions, function (columnDefinition) {
+        if (columnDefinition.selectable !== false) {
+          var name =
+            "<span data-cell-type='column-header-row' " +
+            "       data-test='output-column-header'>" +
+            "  <input data-id='checkbox-" + columnDefinition.id + "' " +
+            "         data-column-id='" + columnDefinition.id + "' " +
+            "         type='checkbox'/>" +
+            "  <span class='column-description'>" + columnDefinition.name + "</span>" +
+            "</span>";
+          return _.extend(columnDefinition, {
+            name: name
+          });
+        } else {
+          return columnDefinition;
+        }
+      });
+    };
+
+    $.extend(this, {
+      "init": init,
+      "getColumnDefinitionsWithCheckboxes": getColumnDefinitionsWithCheckboxes
+    });
+  };
+  return ColumnSelector;
+});
diff --git a/web/pgadmin/static/js/selection/copy_data.js b/web/pgadmin/static/js/selection/copy_data.js
new file mode 100644
index 00000000..b640235e
--- /dev/null
+++ b/web/pgadmin/static/js/selection/copy_data.js
@@ -0,0 +1,42 @@
+define(['jquery', 'underscore', 'sources/selection/clipboard', 'sources/selection/range_boundary_navigator'], function ($, _, clipboard, rangeBoundaryNavigator) {
+  var copyData = function () {
+    var self = this;
+
+    // Disable copy button
+    $("#btn-copy-row").prop('disabled', true);
+    // Enable paste button
+    if (self.can_edit) {
+      $("#btn-paste-row").prop('disabled', false);
+    }
+
+    var grid = self.slickgrid;
+    var columnDefinitions = grid.getColumns();
+    var selectedRanges = grid.getSelectionModel().getSelectedRanges();
+    var data = grid.getData();
+    var rows = grid.getSelectedRows();
+
+
+    if (allTheRangesAreFullRows(selectedRanges, columnDefinitions)) {
+      self.copied_rows = rows.map(function (rowIndex) {
+        return data[rowIndex];
+      });
+    } else {
+      self.copied_rows = [];
+    }
+
+    var csvText = rangeBoundaryNavigator.rangesToCsv(data, columnDefinitions, selectedRanges);
+
+    // If there is something to set into clipboard
+    if (csvText)
+      clipboard.copyTextToClipboard(csvText);
+  };
+
+  var allTheRangesAreFullRows = function (ranges, columnDefinitions){
+    var colRangeBounds = ranges.map(function (range) {
+      return [range.fromCell, range.toCell];
+    });
+    return _.isEqual(_.union.apply(null, colRangeBounds), [0, columnDefinitions.length - 1]);
+  };
+
+  return copyData
+});
\ No newline at end of file
diff --git a/web/pgadmin/static/js/selection/grid_selector.js b/web/pgadmin/static/js/selection/grid_selector.js
new file mode 100644
index 00000000..31aee69f
--- /dev/null
+++ b/web/pgadmin/static/js/selection/grid_selector.js
@@ -0,0 +1,79 @@
+define(['jquery', 'sources/selection/column_selector', 'sources/selection/row_selector'],
+  function ($, ColumnSelector, RowSelector) {
+    var Slick = window.Slick;
+
+    var GridSelector = function (columnDefinitions) {
+      var rowSelector = new RowSelector(columnDefinitions);
+      var columnSelector = new ColumnSelector(columnDefinitions);
+
+      var init = function (grid) {
+        this.grid = grid;
+        grid.onHeaderClick.subscribe(function (event, eventArguments) {
+          if (eventArguments.column.selectAllOnClick) {
+            toggleSelectAll(grid);
+          }
+        });
+
+        grid.getSelectionModel().onSelectedRangesChanged
+          .subscribe(handleSelectedRangesChanged.bind(null, grid));
+        grid.registerPlugin(rowSelector);
+        grid.registerPlugin(columnSelector);
+      };
+
+      var getColumnDefinitionsWithCheckboxes = function (columnDefinitions) {
+        columnDefinitions = columnSelector.getColumnDefinitionsWithCheckboxes(columnDefinitions);
+        columnDefinitions = rowSelector.getColumnDefinitionsWithCheckboxes(columnDefinitions);
+
+        columnDefinitions[0].selectAllOnClick = true;
+        columnDefinitions[0].name = '<input type="checkbox" data-id="checkbox-select-all" ' +
+          'title="Select/Deselect All"/>' + columnDefinitions[0].name;
+        return columnDefinitions;
+      };
+
+      function handleSelectedRangesChanged(grid) {
+        $("[data-id='checkbox-select-all']").prop("checked", isEntireGridSelected(grid));
+      }
+
+      function isEntireGridSelected(grid) {
+        var selectionModel = grid.getSelectionModel();
+        var selectedRanges = selectionModel.getSelectedRanges();
+        return selectedRanges.length == 1 && isSameRange(selectedRanges[0], getRangeOfWholeGrid(grid));
+      }
+
+      function toggleSelectAll(grid) {
+        if (isEntireGridSelected(grid)) {
+          deselect(grid);
+        } else {
+          selectAll(grid)
+        }
+      }
+
+      var isSameRange = function (range, otherRange) {
+        return range.fromCell == otherRange.fromCell && range.toCell == otherRange.toCell &&
+          range.fromRow == otherRange.fromRow && range.toRow == otherRange.toRow;
+      };
+
+      function getRangeOfWholeGrid(grid) {
+        return new Slick.Range(0, 1, grid.getDataLength() - 1, grid.getColumns().length - 1);
+      }
+
+      function deselect(grid) {
+        var selectionModel = grid.getSelectionModel();
+        selectionModel.setSelectedRanges([]);
+      }
+
+      function selectAll(grid) {
+        var range = getRangeOfWholeGrid(grid);
+        var selectionModel = grid.getSelectionModel();
+
+        selectionModel.setSelectedRanges([range]);
+      }
+
+      $.extend(this, {
+        "init": init,
+        "getColumnDefinitionsWithCheckboxes": getColumnDefinitionsWithCheckboxes
+      });
+    };
+
+    return GridSelector;
+  });
diff --git a/web/pgadmin/static/js/selection/range_boundary_navigator.js b/web/pgadmin/static/js/selection/range_boundary_navigator.js
new file mode 100644
index 00000000..f7d64b97
--- /dev/null
+++ b/web/pgadmin/static/js/selection/range_boundary_navigator.js
@@ -0,0 +1,97 @@
+define(function () {
+  return {
+    getUnion: function (dimensionBounds) {
+      dimensionBounds.sort();
+      var boundsUnion = [];
+      if (!_.isEmpty(dimensionBounds)) {
+        boundsUnion = [dimensionBounds[0]];
+      }
+
+      dimensionBounds.forEach(function (bound) {
+        var previousBound = _.last(boundsUnion);
+        if (bound[0] <= previousBound[1] + 1) {
+          if (bound[1] > previousBound[1]) {
+            previousBound[1] = bound[1];
+          }
+        } else {
+          boundsUnion.push(bound);
+        }
+      });
+      return boundsUnion;
+    },
+
+    mapDimensionBoundaryUnion: function (unionedDimensionBoundaries, iteratee) {
+      var mapResult = [];
+      unionedDimensionBoundaries.forEach(function (subrange) {
+        for (var index = subrange[0]; index <= subrange[1]; index += 1) {
+          mapResult.push(iteratee(index));
+        }
+      });
+      return mapResult;
+    },
+
+    mapOver2DArray: function (rowRangeBounds, colRangeBounds, processCell, rowCollector) {
+      var unionedRowRanges = this.getUnion(rowRangeBounds);
+      var unionedColRanges = this.getUnion(colRangeBounds);
+
+      return this.mapDimensionBoundaryUnion(unionedRowRanges, function (rowId) {
+        var rowData = this.mapDimensionBoundaryUnion(unionedColRanges, function (colId) {
+          return processCell(rowId, colId);
+        });
+        return rowCollector(rowData);
+      }.bind(this));
+    },
+
+    rangesToCsv: function (data, columnDefinitions, selectedRanges) {
+      var rowRangeBounds = selectedRanges.map(function (range) {
+        return [range.fromRow, range.toRow];
+      });
+      var colRangeBounds = selectedRanges.map(function (range) {
+        return [range.fromCell, range.toCell];
+      });
+
+      if(_.isUndefined(columnDefinitions[0].pos)){
+        colRangeBounds = this.removeFirstColumn(colRangeBounds);
+      }
+
+      var csvRows = this.mapOver2DArray(rowRangeBounds, colRangeBounds, this.csvCell.bind(this, data, columnDefinitions), function (rowData) {
+        return rowData.join(',');
+      });
+      return csvRows.join('\n');
+    },
+
+    removeFirstColumn: function (colRangeBounds) {
+      var unionedColRanges = this.getUnion(colRangeBounds);
+
+      var firstSubrangeStartsAt0 = function () {
+        return unionedColRanges[0][0] == 0;
+      };
+
+      function firstSubrangeIsJustFirstColumn() {
+        return unionedColRanges[0][1] == 0;
+      }
+
+      if(firstSubrangeStartsAt0()){
+        if(firstSubrangeIsJustFirstColumn()){
+          unionedColRanges.shift();
+        } else {
+          unionedColRanges[0][0] = 1;
+        }
+      }
+      return unionedColRanges;
+    },
+
+    csvCell: function (data, columnDefinitions, rowId, colId) {
+      var val = data[rowId][columnDefinitions[colId].pos];
+
+      if (val && _.isObject(val)) {
+        val = "'" + JSON.stringify(val) + "'";
+      } else if (val && typeof val != "number" && typeof val != "boolean") {
+        val = "'" + val.toString() + "'";
+      } else if (_.isNull(val) || _.isUndefined(val)) {
+        val = '';
+      }
+      return val;
+    }
+  };
+});
\ No newline at end of file
diff --git a/web/pgadmin/static/js/selection/range_selection_helper.js b/web/pgadmin/static/js/selection/range_selection_helper.js
new file mode 100644
index 00000000..c219fcf1
--- /dev/null
+++ b/web/pgadmin/static/js/selection/range_selection_helper.js
@@ -0,0 +1,60 @@
+define(['slickgrid'], function () {
+  var Slick = window.Slick;
+
+  var isSameRange = function (range, otherRange) {
+
+    return range.fromCell == otherRange.fromCell && range.toCell == otherRange.toCell &&
+      range.fromRow == otherRange.fromRow && range.toRow == otherRange.toRow;
+  };
+
+  var isRangeSelected = function (selectedRanges, range) {
+    return _.any(selectedRanges, function (selectedRange) {
+      return isSameRange(selectedRange, range)
+    })
+  };
+
+
+  var removeRange = function (selectedRanges, range) {
+    return _.filter(selectedRanges, function (selectedRange) {
+      return !(isSameRange(selectedRange, range))
+    })
+  };
+
+  var addRange = function (ranges, range) {
+    ranges.push(range);
+    return ranges;
+  };
+
+
+  var areAllRangesRows = function (ranges, grid) {
+    return _.every(ranges, function (range) {
+      return range.fromRow == range.toRow &&
+        range.fromCell == 1 && range.toCell == grid.getColumns().length - 1
+    })
+  };
+
+  var areAllRangesColumns = function (ranges, grid) {
+    return _.every(ranges, function (range) {
+      return range.fromCell == range.toCell &&
+        range.fromRow == 0 && range.toRow == grid.getDataLength() - 1
+    })
+  };
+
+  var rangeForRow = function (grid, rowId) {
+    return new Slick.Range(rowId, 1, rowId, grid.getColumns().length - 1);
+  };
+
+  function rangeForColumn(grid, columnIndex) {
+    return new Slick.Range(0, columnIndex, grid.getDataLength() - 1, columnIndex)
+  }
+
+  return {
+    addRange: addRange,
+    removeRange: removeRange,
+    isRangeSelected: isRangeSelected,
+    areAllRangesRows: areAllRangesRows,
+    areAllRangesColumns: areAllRangesColumns,
+    rangeForRow: rangeForRow,
+    rangeForColumn: rangeForColumn
+  }
+});
\ No newline at end of file
diff --git a/web/pgadmin/static/js/selection/row_selector.js b/web/pgadmin/static/js/selection/row_selector.js
new file mode 100644
index 00000000..76a8c1a7
--- /dev/null
+++ b/web/pgadmin/static/js/selection/row_selector.js
@@ -0,0 +1,85 @@
+define(['jquery', 'sources/selection/range_selection_helper', 'slickgrid'], function ($, rangeSelectionHelper) {
+  var RowSelector = function () {
+    var Slick = window.Slick;
+
+    var gridEventBus = new Slick.EventHandler();
+
+    var init = function (grid) {
+      grid.getSelectionModel()
+        .onSelectedRangesChanged.subscribe(handleSelectedRangesChanged.bind(null, grid));
+      gridEventBus
+        .subscribe(grid.onClick, handleClick.bind(null, grid))
+    };
+
+    var handleClick = function (grid, event, args) {
+      if (grid.getColumns()[args.cell].id === 'row-header-column') {
+        if (event.target.type != "checkbox") {
+          var checkbox = $(event.target).find('input[type="checkbox"]');
+          toggleCheckbox($(checkbox));
+        }
+        updateRanges(grid, args.row);
+      }
+    }
+
+    var handleSelectedRangesChanged = function (grid, event, ranges) {
+      $('[data-cell-type="row-header-checkbox"]:checked')
+        .each(function (index, checkbox) {
+          var $checkbox = $(checkbox);
+          var row = parseInt($checkbox.data('row'));
+          var isStillSelected = rangeSelectionHelper.isRangeSelected(ranges,
+            rangeSelectionHelper.rangeForRow(grid, row));
+          if (!isStillSelected) {
+            toggleCheckbox($checkbox);
+          }
+        });
+    }
+
+    var updateRanges = function (grid, rowId) {
+      var selectionModel = grid.getSelectionModel();
+      var ranges = selectionModel.getSelectedRanges();
+
+      var rowRange = rangeSelectionHelper.rangeForRow(grid, rowId);
+
+      var newRanges;
+      if (rangeSelectionHelper.isRangeSelected(ranges, rowRange)) {
+        newRanges = rangeSelectionHelper.removeRange(ranges, rowRange);
+      } else {
+        if (rangeSelectionHelper.areAllRangesRows(ranges, grid)) {
+          newRanges = rangeSelectionHelper.addRange(ranges, rowRange);
+        } else {
+          newRanges = [rowRange];
+        }
+      }
+      selectionModel.setSelectedRanges(newRanges);
+    }
+
+    var toggleCheckbox = function (checkbox) {
+      if (checkbox.prop("checked")) {
+        checkbox.prop("checked", false)
+      } else {
+        checkbox.prop("checked", true)
+      }
+    };
+
+    var getColumnDefinitionsWithCheckboxes = function (columnDefinitions) {
+      columnDefinitions.unshift({
+        id: 'row-header-column',
+        name: '',
+        selectable: false,
+        focusable: false,
+        formatter: function (rowIndex) {
+          return '<input type="checkbox" ' +
+            'data-row="' + rowIndex + '" ' +
+            'data-cell-type="row-header-checkbox"/>'
+        }
+      });
+      return columnDefinitions;
+    };
+
+    $.extend(this, {
+      "init": init,
+      "getColumnDefinitionsWithCheckboxes": getColumnDefinitionsWithCheckboxes
+    });
+  };
+  return RowSelector;
+});
diff --git a/web/pgadmin/tools/sqleditor/static/css/sqleditor.css b/web/pgadmin/tools/sqleditor/static/css/sqleditor.css
index 6a6f1f8f..d71fc8cf 100644
--- a/web/pgadmin/tools/sqleditor/static/css/sqleditor.css
+++ b/web/pgadmin/tools/sqleditor/static/css/sqleditor.css
@@ -358,6 +358,10 @@ li {
  padding: 4px 0 4px 6px;
 }
 
+.column-description {
+  display: table-cell;
+}
+
 .long_text_editor {
   margin-left: 5px;
   font-size: 12px !important;
@@ -419,6 +423,11 @@ input.editor-checkbox:focus {
   background: #e46b6b;
 }
 
+/* color the first column */
+.sr .sc:first-child {
+    background-color: #2c76b4;
+}
+
 #datagrid div.slick-header.ui-state-default {
   background: #2c76b4;
 }
diff --git a/web/pgadmin/tools/sqleditor/templates/sqleditor/js/sqleditor.js b/web/pgadmin/tools/sqleditor/templates/sqleditor/js/sqleditor.js
index 1bda0679..70412a5f 100644
--- a/web/pgadmin/tools/sqleditor/templates/sqleditor/js/sqleditor.js
+++ b/web/pgadmin/tools/sqleditor/templates/sqleditor/js/sqleditor.js
@@ -2,7 +2,8 @@ define(
   [
     'jquery', 'underscore', 'underscore.string', 'alertify', 'pgadmin',
     'backbone', 'backgrid', 'codemirror', 'pgadmin.misc.explain',
-    'sources/selection/clipboard',
+    'sources/selection/grid_selector', 'sources/selection/clipboard',
+    'sources/selection/copy_data',
 
     'slickgrid', 'bootstrap', 'pgadmin.browser', 'wcdocker',
     'codemirror/mode/sql/sql', 'codemirror/addon/selection/mark-selection',
@@ -21,13 +22,12 @@ define(
     'slickgrid/plugins/slick.cellrangedecorator',
     'slickgrid/plugins/slick.cellrangeselector',
     'slickgrid/plugins/slick.cellselectionmodel',
-    'slickgrid/plugins/slick.checkboxselectcolumn',
     'slickgrid/plugins/slick.cellcopymanager',
     'slickgrid/plugins/slick.rowselectionmodel',
     'slickgrid/slick.grid'
   ],
   function(
-    $, _, S, alertify, pgAdmin, Backbone, Backgrid, CodeMirror, pgExplain, clipboard
+    $, _, S, alertify, pgAdmin, Backbone, Backgrid, CodeMirror, pgExplain, GridSelector, clipboard, copyData
   ) {
     /* Return back, this has been called more than once */
     if (pgAdmin.SqlEditor)
@@ -549,14 +549,7 @@ define(
           collection = [];
         }
 
-        var grid_columns = new Array(),
-          checkboxSelector;
-
-          checkboxSelector = new Slick.CheckboxSelectColumn({
-            cssClass: "sc-cb"
-          });
-
-          grid_columns.push(checkboxSelector.getColumnDefinition());
+        var grid_columns = [];
 
         var grid_width = $($('#editor-panel').find('.wcFrame')[1]).width()
         _.each(columns, function(c) {
@@ -592,6 +585,9 @@ define(
            grid_columns.push(options)
         });
 
+        var gridSelector = new GridSelector();
+        grid_columns = gridSelector.getColumnDefinitionsWithCheckboxes(grid_columns);
+
         var grid_options = {
           editable: true,
           enableAddRow: is_editable,
@@ -635,7 +631,7 @@ define(
         var grid = new Slick.Grid($data_grid, collection, grid_columns, grid_options);
         grid.registerPlugin( new Slick.AutoTooltips({ enableForHeaderCells: false }) );
         grid.setSelectionModel(new Slick.RowSelectionModel({selectActiveRow: false}));
-        grid.registerPlugin(checkboxSelector);
+        grid.registerPlugin(gridSelector);
 
         var editor_data = {
           keys: self.handler.primary_keys,
@@ -2947,46 +2943,7 @@ define(
         },
 
         // This function will copy the selected row.
-        _copy_row: function() {
-          var self = this, grid, data, rows, copied_text = '';
-
-          self.copied_rows = [];
-
-          // Disable copy button
-          $("#btn-copy-row").prop('disabled', true);
-          // Enable paste button
-          if(self.can_edit) {
-            $("#btn-paste-row").prop('disabled', false);
-          }
-
-          grid = self.slickgrid;
-          data = grid.getData();
-          rows = grid.getSelectedRows();
-          // Iterate over all the selected rows & fetch data
-          for (var i = 0; i < rows.length; i += 1) {
-            var idx = rows[i],
-              _rowData = data[idx],
-              _values = [];
-            self.copied_rows.push(_rowData);
-            // Convert it as CSV for clipboard
-            for (var j = 0; j < self.columns.length; j += 1) {
-               var val = _rowData[self.columns[j].pos];
-               if(val && _.isObject(val))
-                 val = "'" + JSON.stringify(val) + "'";
-               else if(val && typeof val != "number" && typeof true != "boolean")
-                 val = "'" + val.toString() + "'";
-               else if (_.isNull(val) || _.isUndefined(val))
-                 val = '';
-                _values.push(val);
-            }
-            // Append to main text string
-            if(_values.length > 0)
-              copied_text += _values.toString() + "\n";
-          }
-          // If there is something to set into clipboard
-          if(copied_text)
-            clipboard.copyTextToClipboard(copied_text);
-        },
+        _copy_row: copyData,
 
         // This function will paste the selected row.
         _paste_row: function() {
diff --git a/web/regression/README b/web/regression/README
index 2eb5c65a..72edaaf8 100644
--- a/web/regression/README
+++ b/web/regression/README
@@ -22,6 +22,12 @@ installed with:
 
 (pgadmin4) $ pip install -r $PGADMIN4_SRC/web/regression/requirements.txt
 
+While running in Linux environments install:
+sudo apt-get install xsel
+
+Otherwise the following error happens:
+"Pyperclip could not find a copy/paste mechanism for your system"
+
 General Information
 -------------------
 
diff --git a/web/regression/feature_utils/base_feature_test.py b/web/regression/feature_utils/base_feature_test.py
index 8bb6bc00..dc704e8e 100644
--- a/web/regression/feature_utils/base_feature_test.py
+++ b/web/regression/feature_utils/base_feature_test.py
@@ -28,6 +28,7 @@ class BaseFeatureTest(BaseTestGenerator):
 
         self.page = PgadminPage(self.driver, app_config)
         try:
+            self.page.driver.switch_to.default_content()
             self.page.wait_for_app()
             self.page.wait_for_spinner_to_disappear()
             self.page.reset_layout()
diff --git a/web/regression/feature_utils/pgadmin_page.py b/web/regression/feature_utils/pgadmin_page.py
index aa31bc04..c1995966 100644
--- a/web/regression/feature_utils/pgadmin_page.py
+++ b/web/regression/feature_utils/pgadmin_page.py
@@ -55,7 +55,18 @@ class PgadminPage:
 
         self.find_by_xpath("//*[@id='tree']//*[.='" + server_config['name'] + "']")
 
+    def close_query_tool(self):
+        self.driver.switch_to.default_content()
+        tab = self.find_by_xpath("//*[contains(@class,'wcPanelTab') and contains(.,'" + "Query" + "')]")
+        ActionChains(self.driver).context_click(tab).perform()
+        self.find_by_xpath("//li[contains(@class, 'context-menu-item')]/span[contains(text(), 'Remove Panel')]").click()
+        self.driver.switch_to.frame(self.driver.find_elements_by_tag_name("iframe")[0])
+        time.sleep(.5)
+        self.click_element(self.find_by_xpath('//button[contains(@class, "ajs-button") and contains(.,"Yes")]'))
+        self.driver.switch_to.default_content()
+
     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()
diff --git a/web/regression/javascript/selection/column_selector_spec.js b/web/regression/javascript/selection/column_selector_spec.js
new file mode 100644
index 00000000..947f4852
--- /dev/null
+++ b/web/regression/javascript/selection/column_selector_spec.js
@@ -0,0 +1,235 @@
+define(
+  ["jquery",
+    "underscore",
+    "slickgrid/slick.grid",
+    "sources/selection/column_selector",
+    "slickgrid/slick.rowselectionmodel",
+    "slickgrid"
+  ],
+  function ($, _, SlickGrid, ColumnSelector, RowSelectionModel, Slick) {
+    describe("ColumnSelector", function () {
+      var container, data, columns, options;
+      beforeEach(function () {
+        container = $("<div></div>");
+        container.height(9999);
+
+        data = [{'some-column-name': 'first value', 'second column': 'second value'}];
+
+        columns = [
+          {
+            id: '1',
+            name: 'some-column-name',
+          },
+          {
+            id: '2',
+            name: 'second column',
+          },
+          {
+            name: 'some-non-selectable-column',
+            selectable: false
+          }
+        ]
+      });
+
+      describe("when a column is not selectable", function () {
+        it("does not create a checkbox for selecting the column", function () {
+          var checkboxColumn = {
+            name: 'some-column-name-4',
+            selectable: false
+          };
+          columns.push(checkboxColumn);
+
+          setupGrid(columns);
+
+          expect(container.find('.slick-header-columns input').length).toBe(2)
+        });
+      });
+
+      it("renders a checkbox in the column header", function () {
+        setupGrid(columns);
+
+        expect(container.find('.slick-header-columns input').length).toBe(2)
+      });
+
+      it("displays the name of the column", function () {
+        setupGrid(columns);
+
+        expect($(container.find('.slick-header-columns .slick-column-name')[0]).text())
+          .toContain('some-column-name');
+        expect($(container.find('.slick-header-columns .slick-column-name')[1]).text())
+          .toContain('second column');
+      });
+
+      it("preserves the other attributes of column definitions", function () {
+        var columnSelector = new ColumnSelector();
+        var selectableColumns = columnSelector.getColumnDefinitionsWithCheckboxes(columns);
+
+        expect(selectableColumns[0].id).toBe('1');
+      });
+
+      describe("selecting columns", function () {
+        var grid, rowSelectionModel;
+        beforeEach(function () {
+          var columnSelector = new ColumnSelector();
+          columns = columnSelector.getColumnDefinitionsWithCheckboxes(columns);
+          data = [];
+          for (var i = 0; i < 10; i++) {
+            data.push({'some-column-name': 'some-value-' + i, 'second column': 'second value ' + i});
+          }
+          grid = new SlickGrid(container, data, columns, options);
+
+          rowSelectionModel = new RowSelectionModel();
+          grid.setSelectionModel(rowSelectionModel);
+
+          grid.registerPlugin(columnSelector);
+          grid.invalidate();
+          $("body").append(container);
+        });
+
+        afterEach(function () {
+          $("body").find(container).remove();
+        });
+
+        describe("when the user clicks a column header", function () {
+          it("selects the column", function () {
+            container.find('.slick-header-column')[0].click();
+            var selectedRanges = rowSelectionModel.getSelectedRanges();
+            expectOnlyTheFirstColumnToBeSelected(selectedRanges);
+          });
+        });
+
+        describe("when the user clicks additional column headers", function () {
+          beforeEach(function () {
+            container.find('.slick-header-column')[1].click();
+          });
+
+          it("selects additional columns", function () {
+            container.find('.slick-header-column')[0].click();
+
+            var selectedRanges = rowSelectionModel.getSelectedRanges();
+            var column1 = selectedRanges[0];
+
+            expect(selectedRanges.length).toEqual(2);
+            expect(column1.fromCell).toBe(1);
+            expect(column1.toCell).toBe(1);
+
+            var column2 = selectedRanges[1];
+
+            expect(column2.fromCell).toBe(0);
+            expect(column2.toCell).toBe(0);
+          });
+        });
+
+        describe("when the user clicks a column header checkbox", function () {
+          it("selects the column", function () {
+            container.find('.slick-header-columns input')[0].click();
+
+            var selectedRanges = rowSelectionModel.getSelectedRanges();
+            expectOnlyTheFirstColumnToBeSelected(selectedRanges);
+          });
+
+          it("checks the checkbox", function () {
+            container.find('.slick-header-column')[1].click();
+            expect($(container.find('.slick-header-columns input')[1]).is(':checked')).toBeTruthy();
+          });
+        });
+
+        describe("when a row is selected", function () {
+          beforeEach(function () {
+            var selectedRanges = [new Slick.Range(0, 0, 0, 1)];
+            rowSelectionModel.setSelectedRanges(selectedRanges);
+          });
+
+          it("deselects the row", function () {
+            container.find('.slick-header-column')[1].click();
+            var selectedRanges = rowSelectionModel.getSelectedRanges();
+
+            expect(selectedRanges.length).toBe(1);
+
+            var column = selectedRanges[0];
+
+            expect(column.fromCell).toBe(1);
+            expect(column.toCell).toBe(1);
+            expect(column.fromRow).toBe(0);
+            expect(column.toRow).toBe(9);
+          })
+        });
+
+        describe("clicking a second time", function () {
+          beforeEach(function () {
+            container.find('.slick-header-column')[1].click();
+          });
+
+          it("unchecks checkbox", function () {
+            container.find('.slick-header-column')[1].click();
+            expect($(container.find('.slick-header-columns input')[1]).is(':checked')).toBeFalsy();
+          });
+
+          it("deselects the column", function () {
+            container.find('.slick-header-column')[1].click();
+            var selectedRanges = rowSelectionModel.getSelectedRanges();
+
+            expect(selectedRanges.length).toEqual(0);
+          })
+        });
+
+        describe("when the column is not selectable", function () {
+          it("does not select the column", function () {
+            $(container.find('.slick-header-column:contains(some-non-selectable-column)')).click();
+            var selectedRanges = rowSelectionModel.getSelectedRanges();
+
+            expect(selectedRanges.length).toEqual(0);
+          });
+        });
+
+        describe("when the column is deselected through setSelectedRanges", function () {
+          beforeEach(function () {
+            container.find('.slick-header-column')[1].click();
+          });
+
+          it("unchecks the checkbox", function () {
+            rowSelectionModel.setSelectedRanges([]);
+
+            expect($(container.find('.slick-header-columns input')[1])
+              .is(':checked')).toBeFalsy();
+          });
+        });
+
+        describe("when a non-column range was already selected", function () {
+          beforeEach(function () {
+            var selectedRanges = [new Slick.Range(0, 0, 1, 0)];
+            rowSelectionModel.setSelectedRanges(selectedRanges);
+          });
+
+          it("deselects the non-column range", function () {
+            container.find('.slick-header-column')[0].click();
+
+            var selectedRanges = rowSelectionModel.getSelectedRanges();
+            expectOnlyTheFirstColumnToBeSelected(selectedRanges);
+          })
+        });
+      });
+
+      var setupGrid = function (columns) {
+        var columnSelector = new ColumnSelector();
+        columns = columnSelector.getColumnDefinitionsWithCheckboxes(columns);
+        var grid = new SlickGrid(container, data, columns, options);
+
+        var rowSelectionModel = new RowSelectionModel();
+        grid.setSelectionModel(rowSelectionModel);
+
+        grid.registerPlugin(columnSelector);
+        grid.invalidate();
+      };
+
+      function expectOnlyTheFirstColumnToBeSelected(selectedRanges) {
+        var row = selectedRanges[0];
+
+        expect(selectedRanges.length).toEqual(1);
+        expect(row.fromCell).toBe(0);
+        expect(row.toCell).toBe(0);
+        expect(row.fromRow).toBe(0);
+        expect(row.toRow).toBe(9);
+      }
+    });
+  });
diff --git a/web/regression/javascript/selection/copy_data_spec.js b/web/regression/javascript/selection/copy_data_spec.js
new file mode 100644
index 00000000..5102512e
--- /dev/null
+++ b/web/regression/javascript/selection/copy_data_spec.js
@@ -0,0 +1,95 @@
+define(
+  ["jquery",
+    "slickgrid/slick.grid",
+    "slickgrid/slick.rowselectionmodel",
+    "sources/selection/copy_data",
+    "sources/selection/clipboard"
+  ],
+  function ($, SlickGrid, RowSelectionModel, copyData, clipboard) {
+    describe('copyData', function () {
+      var grid, sqlEditor;
+
+      beforeEach(function () {
+        var data = [[1, "leopord", "12"],
+          [2, "lion", "13"],
+          [3, "puma", "9"]];
+
+        var columns = [
+          {
+            name: "id",
+            pos: 0,
+            label: "id<br> numeric",
+            cell: "number",
+            can_edit: false,
+            type: "numeric"
+          },
+          {
+            name: "brand",
+            pos: 1,
+            label: "flavor<br> character varying",
+            cell: "string",
+            can_edit: false,
+            type: "character varying"
+          },
+          {
+            name: "size",
+            pos: 2,
+            label: "size<br> numeric",
+            cell: "number",
+            can_edit: false,
+            type: "numeric"
+          }];
+        var gridContainer = $("<div id='grid'></div>");
+        $("body").append(gridContainer);
+        grid = new Slick.Grid("#grid", data, columns, {});
+        grid.setSelectionModel(new Slick.RowSelectionModel({selectActiveRow: false}));
+        sqlEditor = {slickgrid: grid};
+      });
+
+      describe("when rows are selected", function () {
+        beforeEach(function () {
+          grid.getSelectionModel().setSelectedRows([0, 2])
+        });
+
+        it("copies them", function () {
+          spyOn(clipboard, 'copyTextToClipboard');
+
+          copyData.apply(sqlEditor);
+
+          expect(sqlEditor.copied_rows.length).toBe(2);
+
+          expect(clipboard.copyTextToClipboard).toHaveBeenCalled();
+          expect(clipboard.copyTextToClipboard.calls.mostRecent().args[0]).toContain("1,'leopord','12'");
+          expect(clipboard.copyTextToClipboard.calls.mostRecent().args[0]).toContain("3,'puma','9'");
+        });
+      });
+
+      describe("when a column is selected", function () {
+        beforeEach(function () {
+          var firstColumn = new Slick.Range(0, 0, 2, 0);
+          grid.getSelectionModel().setSelectedRanges([firstColumn])
+        });
+
+        it("copies text to the clipboard", function () {
+          spyOn(clipboard, 'copyTextToClipboard');
+
+          copyData.apply(sqlEditor);
+
+          expect(clipboard.copyTextToClipboard).toHaveBeenCalled();
+
+          var copyArg = clipboard.copyTextToClipboard.calls.mostRecent().args[0];
+          var rowStrings = copyArg.split('\n');
+          expect(rowStrings[0]).toBe("1");
+          expect(rowStrings[1]).toBe("2");
+          expect(rowStrings[2]).toBe("3");
+        });
+
+        it("sets copied_rows to empty", function () {
+          copyData.apply(sqlEditor);
+
+          expect(sqlEditor.copied_rows.length).toBe(0);
+        });
+      });
+    });
+  }
+);
\ No newline at end of file
diff --git a/web/regression/javascript/selection/grid_selector_spec.js b/web/regression/javascript/selection/grid_selector_spec.js
new file mode 100644
index 00000000..a74a66f9
--- /dev/null
+++ b/web/regression/javascript/selection/grid_selector_spec.js
@@ -0,0 +1,126 @@
+define(["jquery",
+    "underscore",
+    "slickgrid/slick.grid",
+    "slickgrid/slick.rowselectionmodel",
+    "sources/selection/grid_selector"
+  ],
+  function ($, _, SlickGrid, RowSelectionModel, GridSelector) {
+    describe("GridSelector", function () {
+      var container, data, columns, gridSelector, rowSelectionModel;
+
+      beforeEach(function () {
+        container = $("<div></div>");
+        container.height(9999);
+        columns = [{
+          id: '1',
+          name: 'some-column-name',
+        }, {
+          id: '2',
+          name: 'second column',
+        }];
+
+        gridSelector = new GridSelector();
+        columns = gridSelector.getColumnDefinitionsWithCheckboxes(columns);
+
+        data = [];
+        for (var i = 0; i < 10; i++) {
+          data.push({'some-column-name': 'some-value-' + i, 'second column': 'second value ' + i});
+        }
+        var grid = new SlickGrid(container, data, columns);
+
+        rowSelectionModel = new RowSelectionModel();
+        grid.setSelectionModel(rowSelectionModel);
+
+        grid.registerPlugin(gridSelector);
+        grid.invalidate();
+
+        $("body").append(container);
+      });
+
+      afterEach(function () {
+        $("body").find(container).remove();
+      });
+
+      it("renders an additional column on the left for selecting rows", function () {
+        expect(columns.length).toBe(3);
+
+        var leftmostColumn = columns[0];
+        expect(leftmostColumn.id).toBe('row-header-column');
+      });
+
+      it("renders checkboxes for selecting columns", function () {
+        expect(container.find('[data-test="output-column-header"] input').length).toBe(2)
+      });
+
+      it("renders a checkbox for selecting all the cells", function () {
+        expect(container.find("[title='Select/Deselect All']").length).toBe(1);
+      });
+
+      describe("when the cell for the select/deselect all is clicked", function () {
+        it("selects the whole grid", function () {
+          container.find("[title='Select/Deselect All']").parent().click();
+
+          var selectedRanges = rowSelectionModel.getSelectedRanges();
+          expect(selectedRanges.length).toBe(1);
+          var selectedRange = selectedRanges[0];
+          expect(selectedRange.fromCell).toBe(1);
+          expect(selectedRange.toCell).toBe(2);
+          expect(selectedRange.fromRow).toBe(0);
+          expect(selectedRange.toRow).toBe(9);
+        });
+
+        it("checks the checkbox", function () {
+          container.find("[title='Select/Deselect All']").parent().click();
+
+          expect($(container.find("[data-id='checkbox-select-all']")).is(':checked')).toBeTruthy();
+        })
+      });
+
+      describe("when the main checkbox in the corner gets selected", function () {
+        it("unchecks all the columns", function () {
+          container.find("[title='Select/Deselect All']").click();
+
+          expect($(container.find('.slick-header-columns input')[1]).is(':checked')).toBeFalsy();
+          expect($(container.find('.slick-header-columns input')[2]).is(':checked')).toBeFalsy();
+        });
+
+        it("selects all the cells", function () {
+          container.find("[title='Select/Deselect All']").click();
+
+          var selectedRanges = rowSelectionModel.getSelectedRanges();
+          expect(selectedRanges.length).toBe(1);
+          var selectedRange = selectedRanges[0];
+          expect(selectedRange.fromCell).toBe(1);
+          expect(selectedRange.toCell).toBe(2);
+          expect(selectedRange.fromRow).toBe(0);
+          expect(selectedRange.toRow).toBe(9);
+        });
+
+        describe("when the main checkbox in the corner gets deselected", function () {
+          beforeEach(function () {
+            container.find("[title='Select/Deselect All']").click();
+          });
+
+          it("deselects all the cells", function () {
+            container.find("[title='Select/Deselect All']").click();
+
+            var selectedRanges = rowSelectionModel.getSelectedRanges();
+            expect(selectedRanges.length).toBe(0);
+          });
+        });
+
+        describe("and then the underlying selection changes", function () {
+          beforeEach(function () {
+            container.find("[title='Select/Deselect All']").click();
+          });
+
+          it("unchecks the main checkbox", function () {
+            var ranges = [new Slick.Range(0, 0, 0, 1)];
+            rowSelectionModel.setSelectedRanges(ranges);
+
+            expect($(container.find("[title='Select/Deselect All']")).is(':checked')).toBeFalsy();
+          });
+        });
+      });
+    });
+  });
diff --git a/web/regression/javascript/selection/range_boundary_navigator_spec.js b/web/regression/javascript/selection/range_boundary_navigator_spec.js
new file mode 100644
index 00000000..70fad25f
--- /dev/null
+++ b/web/regression/javascript/selection/range_boundary_navigator_spec.js
@@ -0,0 +1,139 @@
+define(['sources/selection/range_boundary_navigator'], function (rangeBoundaryNavigator) {
+
+  describe("#getUnion", function () {
+    describe("when the ranges completely overlap", function () {
+      it("returns a list with that range", function () {
+        var ranges = [[1, 4], [1, 4], [1, 4]];
+
+        var union = rangeBoundaryNavigator.getUnion(ranges);
+
+        expect(union).toEqual([[1, 4]]);
+      });
+    });
+
+    describe("when the ranges all overlap partially or touch", function () {
+      it("returns one long range", function () {
+        var rangeBounds = [[3, 6], [1, 4], [7, 14]];
+
+        var union = rangeBoundaryNavigator.getUnion(rangeBounds);
+
+        expect(union).toEqual([[1, 14]]);
+      });
+
+      describe("when one range is a subset of another", function () {
+        it("returns the larger range", function () {
+          var rangeBounds = [[2, 6], [1, 14], [8, 10]];
+
+          var union = rangeBoundaryNavigator.getUnion(rangeBounds);
+
+          expect(union).toEqual([[1, 14]]);
+        })
+      })
+    });
+
+    describe("when the ranges do not touch", function () {
+      it("returns them in order from lowest to highest", function () {
+        var rangeBounds = [[3, 6], [1, 1], [8, 10]];
+
+        var union = rangeBoundaryNavigator.getUnion(rangeBounds);
+
+        expect(union).toEqual([[1, 1], [3, 6], [8, 10]]);
+      });
+    });
+  });
+
+
+  describe("#mapDimensionBoundaryUnion", function () {
+    it("returns a list of the results of the callback", function () {
+      var rangeBounds = [[0, 1], [3, 3]];
+      var callback = function () {
+        return 'hello';
+      };
+      var result = rangeBoundaryNavigator.mapDimensionBoundaryUnion(rangeBounds, callback);
+      expect(result).toEqual(['hello', 'hello', 'hello']);
+    });
+
+    it("calls the callback with each index in the dimension", function () {
+      var rangeBounds = [[0, 1], [3, 3]];
+      var callback = jasmine.createSpy('callbackSpy');
+      rangeBoundaryNavigator.mapDimensionBoundaryUnion(rangeBounds, callback);
+      expect(callback.calls.allArgs()).toEqual([[0], [1], [3]]);
+    });
+  });
+
+  describe("#mapOver2DArray", function () {
+    var data, rowCollector, processCell;
+    beforeEach(function () {
+      data = [[0, 1, 2, 3], [2, 2, 2, 2], [4, 5, 6, 7]];
+      processCell = function (rowIndex, columnIndex) {
+        return data[rowIndex][columnIndex];
+      };
+      rowCollector = function (rowData) {
+        return JSON.stringify(rowData);
+      };
+    });
+
+    it("calls the callback for each item in the ranges", function () {
+      var rowRanges = [[0, 0], [2, 2]];
+      var colRanges = [[0, 3]];
+
+      var selectionResult = rangeBoundaryNavigator.mapOver2DArray(rowRanges, colRanges, processCell, rowCollector);
+
+      expect(selectionResult).toEqual(["[0,1,2,3]", "[4,5,6,7]"]);
+    });
+
+    describe("when the ranges are out of order/duplicated", function () {
+      var rowRanges, colRanges;
+      beforeEach(function () {
+        rowRanges = [[2, 2], [2, 2], [0, 0]];
+        colRanges = [[0, 3]];
+      });
+
+      it("uses the union of the ranges", function () {
+        spyOn(rangeBoundaryNavigator, "getUnion").and.callThrough();
+
+        var selectionResult = rangeBoundaryNavigator.mapOver2DArray(rowRanges, colRanges, processCell, rowCollector);
+
+        expect(rangeBoundaryNavigator.getUnion).toHaveBeenCalledWith(rowRanges);
+        expect(rangeBoundaryNavigator.getUnion).toHaveBeenCalledWith(colRanges);
+        expect(selectionResult).toEqual(["[0,1,2,3]", "[4,5,6,7]"]);
+      });
+    });
+  });
+
+  describe("#rangesToCsv", function () {
+    var data, columnDefinitions, ranges;
+    beforeEach(function () {
+      data = [[1, "leopard", "12"],
+        [2, "lion", "13"],
+        [3, "cougar", "9"],
+        [4, "tiger", "10"]];
+      columnDefinitions = [{name: 'id', pos: 0}, {name: 'animal', pos: 1}, {name: 'size', pos: 2}];
+      ranges = [new Slick.Range(0, 0, 0, 2), new Slick.Range(3, 0, 3, 2)];
+    });
+
+    it("returns csv for the provided ranges", function () {
+
+      var csvResult = rangeBoundaryNavigator.rangesToCsv(data, columnDefinitions, ranges);
+
+      expect(csvResult).toEqual("1,'leopard','12'\n4,'tiger','10'");
+    });
+
+    describe("when there is an extra column with checkboxes", function () {
+      beforeEach(function () {
+        columnDefinitions = [{name: 'not-a-data-column'}, {name: 'id', pos: 0}, {name: 'animal', pos: 1}, {
+          name: 'size',
+          pos: 2
+        }];
+        ranges = [new Slick.Range(0, 0, 0, 3), new Slick.Range(3, 0, 3, 3)];
+
+      });
+
+      it("returns csv for the columns with data", function () {
+        var csvResult = rangeBoundaryNavigator.rangesToCsv(data, columnDefinitions, ranges);
+
+        expect(csvResult).toEqual("1,'leopard','12'\n4,'tiger','10'");
+      });
+    });
+  });
+});
\ No newline at end of file
diff --git a/web/regression/javascript/selection/row_selector_spec.js b/web/regression/javascript/selection/row_selector_spec.js
new file mode 100644
index 00000000..10697e6a
--- /dev/null
+++ b/web/regression/javascript/selection/row_selector_spec.js
@@ -0,0 +1,174 @@
+define(
+  ["jquery",
+    "underscore",
+    "slickgrid/slick.grid",
+    "sources/selection/row_selector",
+    "slickgrid/slick.rowselectionmodel",
+    "slickgrid",
+  ],
+  function ($, _, SlickGrid, RowSelector, RowSelectionModel, Slick) {
+    describe("RowSelector", function () {
+      var container, data, columnDefinitions, grid, rowSelectionModel;
+
+      beforeEach(function () {
+        container = $("<div></div>");
+        container.height(9999);
+
+        columnDefinitions = [{
+          id: '1',
+          name: 'some-column-name',
+          selectable: true
+        }, {
+          id: '2',
+          name: 'second column',
+          selectable: true
+        }];
+
+        var rowSelector = new RowSelector();
+        data = [];
+        for (var i = 0; i < 10; i++) {
+          data.push(['some-value-' + i, 'second value ' + i]);
+        }
+        columnDefinitions = rowSelector.getColumnDefinitionsWithCheckboxes(columnDefinitions);
+        grid = new SlickGrid(container, data, columnDefinitions);
+
+        rowSelectionModel = new RowSelectionModel();
+        grid.setSelectionModel(rowSelectionModel);
+        grid.registerPlugin(rowSelector);
+        grid.invalidate();
+
+        $("body").append(container);
+      });
+
+      afterEach(function () {
+        $("body").find(container).remove();
+      });
+
+      it("renders an additional column on the left", function () {
+        expect(columnDefinitions.length).toBe(3);
+
+        var leftmostColumn = columnDefinitions[0];
+        expect(leftmostColumn.id).toBe('row-header-column');
+        expect(leftmostColumn.name).toBe('');
+        expect(leftmostColumn.selectable).toBe(false);
+      });
+
+      it("renders a checkbox the leftmost column", function () {
+        expect(container.find('.sr').length).toBe(11);
+        expect(container.find('.sr .sc:first-child input[type="checkbox"]').length).toBe(10);
+      });
+
+      it("preserves the other attributes of column definitions", function () {
+        expect(columnDefinitions[1].id).toBe('1');
+        expect(columnDefinitions[1].selectable).toBe(true);
+      });
+
+      describe("selecting rows", function () {
+        describe("when the user clicks a row header checkbox", function () {
+          it("selects the row", function () {
+            container.find('.sr .sc:first-child input[type="checkbox"]')[0].click();
+
+            var selectedRanges = rowSelectionModel.getSelectedRanges();
+            expectOnlyTheFirstRowToBeSelected(selectedRanges);
+          });
+
+          it("checks the checkbox", function () {
+            container.find('.sr .sc:first-child input[type="checkbox"]')[5].click();
+
+            expect($(container.find('.sr .sc:first-child input[type="checkbox"]')[5])
+              .is(':checked')).toBeTruthy();
+          });
+        });
+
+        describe("when the user clicks a row header", function () {
+          it("selects the row", function () {
+            container.find('.sr .sc:first-child')[0].click();
+
+            var selectedRanges = rowSelectionModel.getSelectedRanges();
+            expectOnlyTheFirstRowToBeSelected(selectedRanges);
+          });
+
+          it("checks the checkbox", function () {
+            container.find('.sr .sc:first-child')[7].click();
+
+            expect($(container.find('.sr .sc:first-child input[type="checkbox"]')[7])
+              .is(':checked')).toBeTruthy();
+          });
+        });
+
+        describe("when the user clicks multiple row headers", function () {
+          it("selects another row", function () {
+            container.find('.sr .sc:first-child')[4].click();
+            container.find('.sr .sc:first-child')[0].click();
+
+            var selectedRanges = rowSelectionModel.getSelectedRanges();
+            expect(selectedRanges.length).toEqual(2);
+
+            var row1 = selectedRanges[0];
+            expect(row1.fromRow).toBe(4);
+            expect(row1.toRow).toBe(4);
+
+            var row2 = selectedRanges[1];
+            expect(row2.fromRow).toBe(0);
+            expect(row2.toRow).toBe(0);
+          });
+        });
+
+        describe("when a column was already selected", function () {
+          beforeEach(function () {
+            var selectedRanges = [new Slick.Range(0, 0, 0, 1)];
+            rowSelectionModel.setSelectedRanges(selectedRanges);
+          });
+
+          it("deselects the column", function () {
+            container.find('.sr .sc:first-child')[0].click();
+            var selectedRanges = rowSelectionModel.getSelectedRanges();
+
+            expectOnlyTheFirstRowToBeSelected(selectedRanges);
+          })
+        });
+
+        describe("when the row is deselected through setSelectedRanges", function () {
+          beforeEach(function () {
+            container.find('.sr .sc:first-child')[4].click();
+          });
+
+          it("should uncheck the checkbox", function () {
+            rowSelectionModel.setSelectedRanges([]);
+
+            expect($(container.find('.sr .sc:first-child input[type="checkbox"]')[4])
+              .is(':checked')).toBeFalsy();
+          });
+        });
+
+        describe("click a second time", function () {
+          beforeEach(function () {
+            container.find('.sr .sc:first-child')[1].click();
+          });
+
+          it("unchecks checkbox", function () {
+            container.find('.sr .sc:first-child')[1].click();
+            expect($(container.find('.sr .sc:first-child input[type="checkbox"]')[1])
+              .is(':checked')).toBeFalsy();
+          });
+
+          it("unselects the row", function () {
+            container.find('.sr .sc:first-child')[1].click();
+            var selectedRanges = rowSelectionModel.getSelectedRanges();
+
+            expect(selectedRanges.length).toEqual(0);
+          })
+        });
+      });
+    });
+
+    function expectOnlyTheFirstRowToBeSelected(selectedRanges) {
+      var row = selectedRanges[0];
+
+      expect(selectedRanges.length).toEqual(1);
+      expect(row.fromCell).toBe(1);
+      expect(row.toCell).toBe(2);
+      expect(row.fromRow).toBe(0);
+      expect(row.toRow).toBe(0);
+    }
+  });
\ 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 ada3f829..c7808e92 100644
--- a/web/regression/python_test_utils/test_utils.py
+++ b/web/regression/python_test_utils/test_utils.py
@@ -166,6 +166,7 @@ def create_table(server, db_name, table_name):
         pg_cursor = connection.cursor()
         pg_cursor.execute('''CREATE TABLE "%s" (some_column VARCHAR, value NUMERIC)''' % table_name)
         pg_cursor.execute('''INSERT INTO "%s" VALUES ('Some-Name', 6)''' % table_name)
+        pg_cursor.execute('''INSERT INTO "%s" VALUES ('Some-Other-Name', 22)''' % table_name)
         connection.set_isolation_level(old_isolation_level)
         connection.commit()
 
diff --git a/web/regression/requirements.txt b/web/regression/requirements.txt
index f644c12a..693ea177 100644
--- a/web/regression/requirements.txt
+++ b/web/regression/requirements.txt
@@ -1,4 +1,5 @@
 chromedriver_installer==0.0.6
+pyperclip~=1.5.27
 selenium==3.3.1
 testscenarios==0.5.0
 testtools==2.0.0
-- 
2.12.0

