diff --git a/docs/en_US/images/query_output_data.png b/docs/en_US/images/query_output_data.png old mode 100755 new mode 100644 index e494523a1..a23815ec7 Binary files a/docs/en_US/images/query_output_data.png and b/docs/en_US/images/query_output_data.png differ 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 000000000..223579eee --- /dev/null +++ b/web/pgadmin/feature_tests/copy_selected_query_results_feature_test.py @@ -0,0 +1,76 @@ +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): + """ + Tests various ways to copy data from the query results grid. + """ + + + scenarios = [ + ("Test Copying Query Results", 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.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 000000000..c89b3fa8d --- /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 = + "" + + " " + + " " + columnDefinition.name + "" + + ""; + 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 000000000..018efeadb --- /dev/null +++ b/web/pgadmin/static/js/selection/copy_data.js @@ -0,0 +1,52 @@ +define([ + 'jquery', + 'underscore', + 'sources/selection/clipboard', + 'sources/selection/range_selection_helper', + 'sources/selection/range_boundary_navigator'], + function ($, _, clipboard, RangeSelectionHelper, rangeBoundaryNavigator) { + var copyData = function () { + var self = this; + + 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]; + }); + setPasteRowButtonEnablement(self.can_edit, true); + } else { + self.copied_rows = []; + setPasteRowButtonEnablement(self.can_edit, false); + } + + var csvText = rangeBoundaryNavigator.rangesToCsv(data, columnDefinitions, selectedRanges); + if (csvText) { + clipboard.copyTextToClipboard(csvText); + } + }; + + var setPasteRowButtonEnablement = function (canEditFlag, isEnabled) { + if (canEditFlag) { + $("#btn-paste-row").prop('disabled', !isEnabled); + } + }; + + var allTheRangesAreFullRows = function (ranges, columnDefinitions) { + var colRangeBounds = ranges.map(function (range) { + return [range.fromCell, range.toCell]; + }); + + if(RangeSelectionHelper.isFirstColumnData(columnDefinitions)) { + return _.isEqual(_.union.apply(null, colRangeBounds), [0, columnDefinitions.length - 1]); + } + return _.isEqual(_.union.apply(null, colRangeBounds), [1, columnDefinitions.length - 1]); + }; + + return copyData; +}); 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 000000000..31aee69f1 --- /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 = '' + 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 000000000..a268d245f --- /dev/null +++ b/web/pgadmin/static/js/selection/range_boundary_navigator.js @@ -0,0 +1,111 @@ +define(['sources/selection/range_selection_helper'], function (RangeSelectionHelper) { + return { + getUnion: function (allRanges) { + if (_.isEmpty(allRanges)) { + return []; + } + + allRanges.sort(firstElementNumberComparator); + var unionedRanges = [allRanges[0]]; + + allRanges.forEach(function (range) { + var maxBeginningOfRange = _.last(unionedRanges); + if (isStartInsideRange(range, maxBeginningOfRange)) { + if (!isEndInsideRange(range, maxBeginningOfRange)) { + maxBeginningOfRange[1] = range[1]; + } + } else { + unionedRanges.push(range); + } + }); + + return unionedRanges; + + function firstElementNumberComparator(a, b) { + return a[0] - b[0]; + } + + function isStartInsideRange(range, surroundingRange) { + return range[0] <= surroundingRange[1] + 1; + } + + function isEndInsideRange(range, surroundingRange) { + return range[1] <= surroundingRange[1]; + } + }, + + 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 (!RangeSelectionHelper.isFirstColumnData(columnDefinitions)) { + 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 000000000..31ad3bf79 --- /dev/null +++ b/web/pgadmin/static/js/selection/range_selection_helper.js @@ -0,0 +1,78 @@ +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) { + var columnDefinitions = grid.getColumns(); + if(isFirstColumnData(columnDefinitions)) { + return new Slick.Range(rowId, 0, rowId, grid.getColumns().length - 1); + } + 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) + }; + + var getRangeOfWholeGrid = function (grid) { + return new Slick.Range(0, 1, grid.getDataLength() - 1, grid.getColumns().length - 1); + }; + + var isEntireGridSelected = function (grid) { + var selectionModel = grid.getSelectionModel(); + var selectedRanges = selectionModel.getSelectedRanges(); + return selectedRanges.length == 1 && isSameRange(selectedRanges[0], getRangeOfWholeGrid(grid)); + }; + + var isFirstColumnData = function (columnDefinitions) { + return !_.isUndefined(columnDefinitions[0].pos); + }; + + return { + addRange: addRange, + removeRange: removeRange, + isRangeSelected: isRangeSelected, + areAllRangesRows: areAllRangesRows, + areAllRangesColumns: areAllRangesColumns, + rangeForRow: rangeForRow, + rangeForColumn: rangeForColumn, + isEntireGridSelected: isEntireGridSelected, + getRangeOfWholeGrid: getRangeOfWholeGrid, + isFirstColumnData: isFirstColumnData + } +}); \ 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 000000000..76a8c1a7e --- /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 '' + } + }); + return columnDefinitions; + }; + + $.extend(this, { + "init": init, + "getColumnDefinitionsWithCheckboxes": getColumnDefinitionsWithCheckboxes + }); + }; + return RowSelector; +}); diff --git a/web/pgadmin/tools/datagrid/templates/datagrid/index.html b/web/pgadmin/tools/datagrid/templates/datagrid/index.html index 1d71b21e4..727eb777f 100644 --- a/web/pgadmin/tools/datagrid/templates/datagrid/index.html +++ b/web/pgadmin/tools/datagrid/templates/datagrid/index.html @@ -96,7 +96,7 @@
- "); + grid = new Slick.Grid("#grid", data, columns, {}); + grid.setSelectionModel(new Slick.RowSelectionModel({selectActiveRow: false})); + sqlEditor = {slickgrid: grid}; + }); + + afterEach(function() { + $("body").remove('#grid'); + $("body").remove('#btn-paste-row'); + }); + + describe("when rows are selected", function () { + beforeEach(function () { + grid.getSelectionModel().setSelectedRanges([ + RangeSelectionHelper.rangeForRow(grid, 0), + RangeSelectionHelper.rangeForRow(grid, 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 the user can edit the grid", function () { + it("enables the paste row button", function () { + copyData.apply(_.extend({can_edit: true}, sqlEditor)); + + expect($("#btn-paste-row").prop('disabled')).toBe(false); + }); + }); + }); + + 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); + }); + + describe("when the user can edit the grid", function () { + it("disables the paste row button", function () { + copyData.apply(_.extend({can_edit: true}, sqlEditor)); + + expect($("#btn-paste-row").prop('disabled')).toBe(true); + }); + }); + }); + }); + }); 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 000000000..a74a66f93 --- /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 = $("
"); + 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 000000000..8376d0a78 --- /dev/null +++ b/web/regression/javascript/selection/range_boundary_navigator_spec.js @@ -0,0 +1,158 @@ +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]]); + }); + + it("returns them in order from lowest to highest", function () { + var rangeBounds = [[3, 6], [2, 3], [10, 12]]; + + var union = rangeBoundaryNavigator.getUnion(rangeBounds); + + expect(union).toEqual([[2, 6], [10, 12]]); + }); + + describe("when one range completely overlaps another", function() { + + it("returns them in order from lowest to highest", function () { + var rangeBounds = [[9, 14], [2, 3], [11, 13]]; + + var union = rangeBoundaryNavigator.getUnion(rangeBounds); + + expect(union).toEqual([[2, 3], [9, 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 000000000..10697e6a1 --- /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 = $("
"); + 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 f3a6ce8ce..2b7c6954e 100644 --- a/web/regression/python_test_utils/test_utils.py +++ b/web/regression/python_test_utils/test_utils.py @@ -163,6 +163,9 @@ def create_table(server, db_name, table_name): 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 f644c12a6..693ea1771 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