diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index 8e328308..a3bddccd 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -3,6 +3,8 @@ var ko = require('knockout'); var moment = require('moment-timezone'); var {getSelectionDesc} = require('app/common/DocActions'); var {nativeCompare, roundDownToMultiple, waitObs} = require('app/common/gutil'); +var gutil = require('app/common/gutil'); +const MANUALSORT = require('app/common/gristTypes').MANUALSORT; var gristTypes = require('app/common/gristTypes'); var tableUtil = require('../lib/tableUtil'); var {DataRowModel} = require('../models/DataRowModel'); @@ -231,6 +233,7 @@ BaseView.commonCommands = { copyLink: function() { this.copyLink().catch(reportError); }, filterByThisCellValue: function() { this.filterByThisCellValue(); }, + duplicateRows: function() { this._duplicateRows().catch(reportError); } }; /** @@ -648,4 +651,69 @@ BaseView.prototype.scrollToCursor = function() { return Promise.resolve(); }; +/** + * Return a list of manual sort positions so that inserting {numInsert} rows + * with the returned positions will place them in between index-1 and index. + * when the GridView is sorted by MANUALSORT + **/ + BaseView.prototype._getRowInsertPos = function(index, numInserts) { + var lowerRowId = this.viewData.getRowId(index-1); + var upperRowId = this.viewData.getRowId(index); + if (lowerRowId === 'new') { + // set the lowerRowId to the rowId of the row before 'new'. + lowerRowId = this.viewData.getRowId(index - 2); + } + + var lowerPos = this.tableModel.tableData.getValue(lowerRowId, MANUALSORT); + var upperPos = this.tableModel.tableData.getValue(upperRowId, MANUALSORT); + // tableUtil.insertPositions takes care of cases where upper/lowerPos are non-zero & falsy + return tableUtil.insertPositions(lowerPos, upperPos, numInserts); +}; + +/** + * Duplicates selected row(s) and returns inserted rowIds + */ +BaseView.prototype._duplicateRows = async function() { + if (this.viewSection.disableAddRemoveRows() || this.disableEditing()) { + return; + } + // Get current selection (we need only rowIds). + const selection = this.getSelection(); + const rowIds = selection.rowIds; + const length = rowIds.length; + // Start assembling action. + const action = ['BulkAddRecord']; + // Put nulls as rowIds. + action.push(gutil.arrayRepeat(length, null)); + const columns = {}; + action.push(columns); + // Calculate new positions for rows using helper function. It requires + // index where we want to put new rows (it accepts new row index). + const lastSelectedIndex = this.viewData.getRowIndex(rowIds[length-1]); + columns.manualSort = this._getRowInsertPos(lastSelectedIndex + 1, length); + // Now copy all visible data. + for(const col of this.viewSection.columns.peek()) { + // But omit all formula columns (and empty ones). + const colId = col.colId.peek(); + if (col.isFormula.peek()) { + continue; + } + columns[colId] = rowIds.map(id => this.tableModel.tableData.getValue(id, colId)); + // If all values in a column are censored, remove this column, + if (columns[colId].every(gristTypes.isCensored)) { + delete columns[colId] + } else { + // else remove only censored values + columns[colId].forEach((val, i) => { + if (gristTypes.isCensored(val)) { + columns[colId][i] = null; + } + }) + } + } + const result = await this.sendTableAction(action, `Duplicated rows ${rowIds}`); + return result; +} + + module.exports = BaseView; diff --git a/app/client/components/DetailView.js b/app/client/components/DetailView.js index a21fe2b3..1a3db0c2 100644 --- a/app/client/components/DetailView.js +++ b/app/client/components/DetailView.js @@ -216,6 +216,7 @@ DetailView.prototype.buildContextMenu = function(row, options) { disableInsert: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.tableModel.tableMetaRow.onDemand()), disableDelete: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || row._isAddRow()), isViewSorted: this.viewSection.activeSortSpec.peek().length > 0, + numRows: this.getSelection().rowIds.length, }; return RowContextMenu(options ? Object.assign(defaults, options) : defaults); } @@ -422,4 +423,9 @@ DetailView.prototype.scrollToCursor = function(sync = true) { return kd.doScrollChildIntoView(this.scrollPane, this.cursor.rowIndex(), sync); } +DetailView.prototype._duplicateRows = async function() { + const addRowIds = await BaseView.prototype._duplicateRows.call(this); + this.setCursorPos({rowId: addRowIds[0]}) +} + module.exports = DetailView; diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 1a80615d..3164e2cf 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -6,7 +6,6 @@ const debounce = require('lodash/debounce'); var gutil = require('app/common/gutil'); var BinaryIndexedTree = require('app/common/BinaryIndexedTree'); -var MANUALSORT = require('app/common/gristTypes').MANUALSORT; const {Sort} = require('app/common/SortSpec'); var dom = require('../lib/dom'); @@ -748,25 +747,6 @@ GridView.prototype.moveRows = function(oldIndices, newIndex) { }); }; -/** - * Return a list of manual sort positions so that inserting {numInsert} rows - * with the returned positions will place them in between index-1 and index. - * when the GridView is sorted by MANUALSORT - **/ -GridView.prototype._getRowInsertPos = function(index, numInserts) { - var lowerRowId = this.viewData.getRowId(index-1); - var upperRowId = this.viewData.getRowId(index); - if (lowerRowId === 'new') { - // set the lowerRowId to the rowId of the row before 'new'. - lowerRowId = this.viewData.getRowId(index - 2); - } - - var lowerPos = this.tableModel.tableData.getValue(lowerRowId, MANUALSORT); - var upperPos = this.tableModel.tableData.getValue(upperRowId, MANUALSORT); - // tableUtil.insertPositions takes care of cases where upper/lowerPos are non-zero & falsy - return tableUtil.insertPositions(lowerPos, upperPos, numInserts); -}; - // ====================================================================================== // MISC HELPERS @@ -1574,7 +1554,7 @@ GridView.prototype._getRowContextMenuOptions = function() { disableInsert: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.tableModel.tableMetaRow.onDemand()), disableDelete: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.getSelection().onlyAddRowSelected()), isViewSorted: this.viewSection.activeSortSpec.peek().length > 0, - numRows: this.getSelection().rowIds.length + numRows: this.getSelection().rowIds.length, }; }; @@ -1591,6 +1571,19 @@ GridView.prototype.scrollToCursor = function(sync = true) { return kd.doScrollChildIntoView(this.scrollPane, this.cursor.rowIndex(), sync); } +GridView.prototype._duplicateRows = async function() { + const addRowIds = await BaseView.prototype._duplicateRows.call(this); + // Highlight duplicated rows if the grid is not sorted (or the sort doesn't affect rowIndex). + const topRowIndex = this.viewData.getRowIndex(addRowIds[0]); + // Set row on the first record added. + this.setCursorPos({rowId: addRowIds[0]}); + // Highlight inserted area (if we inserted rows in correct order) + if (addRowIds.every((r, i) => r === this.viewData.getRowId(topRowIndex + i))) { + this.cellSelector.selectArea(topRowIndex, 0, + topRowIndex + addRowIds.length - 1, this.viewSection.viewFields().peekLength - 1); + } +} + // Helper to show tooltip over column selection in the full edit mode. class HoverColumnTooltip { constructor(el) { diff --git a/app/client/components/commandList.js b/app/client/components/commandList.js index ca5bb1d6..58a34bd4 100644 --- a/app/client/components/commandList.js +++ b/app/client/components/commandList.js @@ -360,6 +360,10 @@ exports.groups = [{ name: 'deleteSection', keys: [], desc: 'Delete the currently active viewsection' + }, { + name: 'duplicateRows', + keys: ['Ctrl+Shift+d'], + desc: 'Duplicate selected rows' } ], }, { diff --git a/app/client/ui/CellContextMenu.ts b/app/client/ui/CellContextMenu.ts index b4f1d7ef..b6a6c3bb 100644 --- a/app/client/ui/CellContextMenu.ts +++ b/app/client/ui/CellContextMenu.ts @@ -1,14 +1,8 @@ import { allCommands } from 'app/client/components/commands'; import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus'; -import { dom } from 'grainjs'; import { IMultiColumnContextMenu } from 'app/client/ui/GridViewMenus'; - -interface IRowContextMenu { - disableInsert: boolean; - disableDelete: boolean; - isViewSorted: boolean; - numRows: number; -} +import { IRowContextMenu } from 'app/client/ui/RowContextMenu'; +import { dom } from 'grainjs'; export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiColumnContextMenu) { @@ -65,7 +59,8 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC menuItemCmd(allCommands.insertRecordAfter, 'Insert row below', dom.cls('disabled', disableInsert))] ), - + menuItemCmd(allCommands.duplicateRows, `Duplicate ${numRows === 1 ? 'row' : 'rows'}`, + dom.cls('disabled', disableInsert || numRows === 0)), menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left', disableForReadonlyView), menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right', diff --git a/app/client/ui/RowContextMenu.ts b/app/client/ui/RowContextMenu.ts index 84620327..31fb3058 100644 --- a/app/client/ui/RowContextMenu.ts +++ b/app/client/ui/RowContextMenu.ts @@ -2,13 +2,14 @@ import { allCommands } from 'app/client/components/commands'; import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus'; import { dom } from 'grainjs'; -interface IRowContextMenu { +export interface IRowContextMenu { disableInsert: boolean; disableDelete: boolean; isViewSorted: boolean; + numRows: number; } -export function RowContextMenu({ disableInsert, disableDelete, isViewSorted }: IRowContextMenu) { +export function RowContextMenu({ disableInsert, disableDelete, isViewSorted, numRows }: IRowContextMenu) { const result: Element[] = []; if (isViewSorted) { // When the view is sorted, any newly added records get shifts instantly at the top or @@ -26,6 +27,10 @@ export function RowContextMenu({ disableInsert, disableDelete, isViewSorted }: I dom.cls('disabled', disableInsert)), ); } + result.push( + menuItemCmd(allCommands.duplicateRows, `Duplicate ${numRows === 1 ? 'row' : 'rows'}`, + dom.cls('disabled', disableInsert || numRows === 0)), + ); result.push( menuDivider(), // TODO: should show `Delete ${num} rows` when multiple are selected diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index f7d43721..fb53342e 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -251,6 +251,45 @@ export async function getVisibleGridCells( return rowNums.map((n) => fields[visibleRowNums.indexOf(n)]); } +/** + * Experimental fast version of getVisibleGridCells that reads data directly from browser by + * invoking javascript code. + */ +export async function getVisibleGridCellsFast(col: string, rowNums: number[]): Promise +export async function getVisibleGridCellsFast(options: {cols: string[], rowNums: number[]}): Promise +export async function getVisibleGridCellsFast(colOrOptions: any, rowNums?: number[]): Promise{ + if (rowNums) { + return getVisibleGridCellsFast({cols: [colOrOptions], rowNums}); + } + // Make sure we have active section. + await driver.findWait('.active_section', 4000); + const cols = colOrOptions.cols; + const rows = colOrOptions.rowNums; + const result = await driver.executeScript(` + const cols = arguments[0]; + const rowNums = arguments[1]; + // Read all columns and create object { ['ColName'] : index } + const columns = Object.fromEntries([...document.querySelectorAll(".g-column-label")] + .map((col, index) => [col.innerText, index])) + const result = []; + // Read all rows and create object { [rowIndex] : RowNumberElement } + const rowNumElements = Object.fromEntries([...document.querySelectorAll(".gridview_data_row_num")] + .map((row) => [Number(row.innerText), row])) + for(const r of rowNums) { + // If this is addRow, insert undefined x cols.length. + if (rowNumElements[r].parentElement.querySelector('.record-add')) { + result.push(...new Array(cols.length)); + continue; + } + // Read all values from a row, and create an object { [cellIndex] : 'cell value' } + const values = Object.fromEntries([...rowNumElements[String(r)].parentElement.querySelectorAll('.field_clip')] + .map((f, i) => [i, f.innerText])); + result.push(...cols.map(c => values[columns[c]])) + } + return result; `, cols, rows); + return result as string[]; +} + /** * Returns the visible cells of the DetailView in the given field (using column name) at the @@ -367,7 +406,8 @@ export async function getColumnNames() { export async function getCardFieldLabels() { const section = await driver.findWait('.active_section', 4000); - const labels = await section.findAll(".g_record_detail_label", el => el.getText()); + const firstCard = await section.find(".g_record_detail"); + const labels = await firstCard.findAll(".g_record_detail_label", el => el.getText()); return labels; } @@ -956,10 +996,16 @@ export async function begin(invariant: () => any = () => true) { export function revertChanges(test: () => Promise, invariant: () => any = () => false) { return async function() { const revert = await begin(invariant); + let wasError = false; try { await test(); + } catch(e) { + wasError = true; + throw e; } finally { - await revert(); + if (!(noCleanup && wasError)) { + await revert(); + } } }; } @@ -1329,6 +1375,13 @@ export function openRowMenu(rowNum: number) { .then(() => driver.findWait('.grist-floating-menu', 1000)); } +export async function openCardMenu(rowNum: number) { + const section = await driver.find('.active_section'); + const firstRow = await section.findContent('.detail_row_num', String(rowNum)); + await firstRow.find('.test-card-menu-trigger').click(); + return await driver.findWait('.grist-floating-menu', 1000); +} + /** * A helper to complete saving a copy of the document. Namely it is useful to call after clicking * either the `Copy As Template` or `Save Copy` (when on a forked document) button. Accept optional