diff --git a/app/client/components/CellSelector.ts b/app/client/components/CellSelector.ts new file mode 100644 index 00000000..2d4d38d2 --- /dev/null +++ b/app/client/components/CellSelector.ts @@ -0,0 +1,157 @@ +import ko from 'knockout'; +import type BaseView from 'app/client/components/BaseView'; +import type {DataRowModel} from 'app/client/models/DataRowModel'; +import {between} from 'app/common/gutil'; +import {Disposable} from 'grainjs'; + +export const ROW = 'row'; +export const COL = 'col'; +export const CELL = 'cell'; +export const NONE = ''; + +export type ElemType = 'row' | 'col' | 'cell' | ''; + +interface GridView extends BaseView { + domToRowModel(elem: Element, elemType: ElemType): DataRowModel; + domToColModel(elem: Element, elemType: ElemType): DataRowModel; +} + +export class CellSelector extends Disposable { + // row or col.start denotes the anchor/initial index of the select range. + // start is not necessarily smaller than end. + // IE: clicking on col 10 and dragging until the mouse is on col 5 will yield: start = 10, end = 5 + public row = { + start: ko.observable(0), + end: ko.observable(0), + linePos: ko.observable('0px'), // Used by GridView for dragging rows + dropIndex: ko.observable(-1), // Used by GridView for dragging rows + }; + public col = { + start: ko.observable(0), + end: ko.observable(0), + linePos: ko.observable('0px'), // Used by GridView for dragging columns + dropIndex: ko.observable(-1), // Used by GridView for dragging columns + }; + public currentSelectType = ko.observable(NONE); + public currentDragType = ko.observable(NONE); + + constructor(public readonly view: GridView) { + super(); + this.autoDispose(this.view.cursor.rowIndex.subscribe(() => this.setToCursor())); + this.autoDispose(this.view.cursor.fieldIndex.subscribe(() => this.setToCursor())); + this.setToCursor(); + } + + public setToCursor(elemType: ElemType = NONE) { + // Must check that the view contains cursor.rowIndex/cursor.fieldIndex + // in case it has changed. + if (this.view.cursor.rowIndex) { + this.row.start(this.view.cursor.rowIndex()!); + this.row.end(this.view.cursor.rowIndex()!); + } + if (this.view.cursor.fieldIndex) { + this.col.start(this.view.cursor.fieldIndex()); + this.col.end(this.view.cursor.fieldIndex()); + } + this.currentSelectType(elemType); + } + + public containsCell(rowIndex: number, colIndex: number): boolean { + return this.containsCol(colIndex) && this.containsRow(rowIndex); + } + + public containsRow(rowIndex: number): boolean { + return between(rowIndex, this.row.start(), this.row.end()); + } + + public containsCol(colIndex: number): boolean { + return between(colIndex, this.col.start(), this.col.end()); + } + + public isSelected(elem: Element, handlerName: ElemType) { + if (handlerName !== this.currentSelectType()) { + return false; + } + + // TODO: this only works with view: GridView. + // But it seems like we only ever use selectors with gridview anyway + const row = this.view.domToRowModel(elem, handlerName); + const col = this.view.domToColModel(elem, handlerName); + switch (handlerName) { + case ROW: + return this.containsRow(row._index()!); + case COL: + return this.containsCol(col._index()!); + case CELL: + return this.containsCell(row._index()!, col._index()!); + default: + console.error('Given element is not a row, cell or column'); + return false; + } + } + + public isRowSelected(rowIndex: number): boolean { + return this.isCurrentSelectType(COL) || this.containsRow(rowIndex); + } + + public isColSelected(colIndex: number): boolean { + return this.isCurrentSelectType(ROW) || this.containsCol(colIndex); + } + + public isCellSelected(rowIndex: number, colIndex: number): boolean { + return this.isColSelected(colIndex) && this.isRowSelected(rowIndex); + } + + public onlyCellSelected(rowIndex: number, colIndex: number): boolean { + return (this.row.start() === rowIndex && this.row.end() === rowIndex) && + (this.col.start() === colIndex && this.col.end() === colIndex); + } + + public isCurrentSelectType(elemType: ElemType): boolean { + return this._isCurrentType(this.currentSelectType(), elemType); + } + + public isCurrentDragType(elemType: ElemType): boolean { + return this._isCurrentType(this.currentDragType(), elemType); + } + + public colLower(): number { + return Math.min(this.col.start(), this.col.end()); + } + + public colUpper(): number { + return Math.max(this.col.start(), this.col.end()); + } + + public rowLower(): number { + return Math.min(this.row.start(), this.row.end()); + } + + public rowUpper(): number { + return Math.max(this.row.start(), this.row.end()); + } + + public colCount(): number { + return this.colUpper() - this.colLower() + 1; + } + + public rowCount(): number { + return this.rowUpper() - this.rowLower() + 1; + } + + public selectArea(rowStartIdx: number, colStartIdx: number, rowEndIdx: number, colEndIdx: number): void { + this.row.start(rowStartIdx); + this.col.start(colStartIdx); + this.row.end(rowEndIdx); + this.col.end(colEndIdx); + // Only select the area if it's not a single cell + if (this.colCount() > 1 || this.rowCount() > 1) { + this.currentSelectType(CELL); + } + } + + private _isCurrentType(currentType: ElemType, elemType: ElemType): boolean { + console.assert([ROW, COL, CELL, NONE].indexOf(elemType) !== -1); + return currentType === elemType; + } +} diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 39a86760..f16e81d0 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -1,4 +1,4 @@ -/* globals alert, document, $ */ +/* globals alert, $ */ const _ = require('underscore'); const ko = require('knockout'); @@ -19,8 +19,9 @@ const commands = require('./commands'); const viewCommon = require('./viewCommon'); const Base = require('./Base'); const BaseView = require('./BaseView'); -const selector = require('./Selector'); +const selector = require('./CellSelector'); const {CopySelection} = require('./CopySelection'); +const {SelectionSummary} = require('./SelectionSummary'); const koUtil = require('app/client/lib/koUtil'); const convert = require('color-convert'); @@ -39,6 +40,7 @@ const {setPopupToCreateDom} = require('popweasel'); const {CellContextMenu} = require('app/client/ui/CellContextMenu'); const {testId} = require('app/client/ui2018/cssVars'); const {contextMenu} = require('app/client/ui/contextMenu'); +const {mouseDragMatchElem} = require('app/client/ui/mouseDrag'); const {menuToggle} = require('app/client/ui/MenuToggle'); const {showTooltip} = require('app/client/ui/tooltips'); const {parsePasteForView} = require("./BaseView2"); @@ -79,11 +81,9 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) { this.scrollTop = ko.observable(0); this.isScrolledTop = this.autoDispose(ko.computed(() => this.scrollTop() > 0)); - this.cellSelector = this.autoDispose(selector.CellSelector.create(this, { - // This is a bit of a hack to prevent dragging when there's an open column menu - // TODO: disable dragging when there is an open cell context menu as well - isDisabled: () => Boolean(!this.ctxMenuHolder.isEmpty()) - })); + this.cellSelector = selector.CellSelector.create(this, this); + this.selectionSummary = SelectionSummary.create(this, + this.cellSelector, this.tableModel.tableData, this.sortedRows, this.viewSection.viewFields); this.colMenuTargets = {}; // Reference from column ref to its menu target dom // Cache of column right offsets, used to determine the col select range @@ -856,7 +856,6 @@ GridView.prototype._getColStyle = function(colIndex) { return { 'width' : this.viewSection.viewFields().at(colIndex).widthPx() }; }; - // TODO: for now lets just assume you are clicking on a .field, .row, or .column GridView.prototype.domToRowModel = function(elem, elemType) { switch (elemType) { @@ -1359,19 +1358,19 @@ GridView.prototype.rowMouseDown = function(elem, event) { } }; -GridView.prototype.rowMouseMove = function(elem, event) { +GridView.prototype.rowMouseMove = function(event) { this.cellSelector.row.end(this.currentMouseRow(event.pageY)); }; -GridView.prototype.colMouseMove = function(elem, event) { +GridView.prototype.colMouseMove = function(event) { var currentCol = Math.min(this.getMousePosCol(event.pageX), this.viewSection.viewFields().peekLength - 1); this.cellSelector.col.end(currentCol); }; -GridView.prototype.cellMouseMove = function(elem, event, extra) { - this.colMouseMove(elem, event); - this.rowMouseMove(elem, event); +GridView.prototype.cellMouseMove = function(event) { + this.colMouseMove(event); + this.rowMouseMove(event); // Maintain single cells cannot be selected invariant if (this.cellSelector.onlyCellSelected(this.cursor.rowIndex(), this.cursor.fieldIndex())) { this.cellSelector.currentSelectType(selector.NONE); @@ -1388,58 +1387,70 @@ GridView.prototype.createSelector = function() { // but we can't attach any of the mouse handlers in the Selector class until the // dom elements exist so we attach the selector handlers separately from instantiation GridView.prototype.attachSelectorHandlers = function () { - // We attach mousemove and mouseup to document so that selecting and drag/dropping - // work even if the mouse leaves the view pane: http://news.qooxdoo.org/mouse-capturing - // Mousemove/up events fire to document even if the mouse leaves the browser window. - var rowCallbacks = { - 'disableDrag': this.viewSection.disableDragRows, - 'mousedown': { 'select': this.rowMouseDown, - 'drag': this.styleRowDragElements, - 'elemName': '.gridview_data_row_num', - 'source': this.viewPane, - }, - 'mousemove': { 'select': this.rowMouseMove, - 'drag': this.dragRows, - 'source': document, - }, - 'mouseup': { 'select': this.rowMouseUp, - 'drag': this.dropRows, - 'source': document, - } - }; - var colCallbacks = { - 'mousedown': { 'select': this.colMouseDown, - 'drag': this.styleColDragElements, - // Trigger on column headings but not on the add column button - 'elemName': '.column_name.field:not(.mod-add-column)', - 'source': this.viewPane, - }, - 'mousemove': { 'select': this.colMouseMove, - 'drag': this.dragCols, - 'source': document, - }, - 'mouseup': { 'drag': this.dropCols, - 'source': document, + const ignoreEvent = (event, elem) => ( + event.button !== 0 || + event.target.classList.contains('ui-resizable-handle') || + // This is a bit of a hack to prevent dragging when there's an open column menu + // TODO: disable dragging when there is an open cell context menu as well + !this.ctxMenuHolder.isEmpty() + ); + + this.autoDispose(mouseDragMatchElem(this.viewPane, '.gridview_data_row_num', (event, elem) => { + if (!ignoreEvent(event, elem)) { + if (!this.cellSelector.isSelected(elem, selector.ROW)) { + this.rowMouseDown(elem, event); + return { + onMove: (ev) => this.rowMouseMove(ev), + onStop: (ev) => {}, + }; + } else if (!this.viewSection.disableDragRows()) { + this.styleRowDragElements(elem, event); + return { + onMove: (ev) => this.dragRows(ev), + onStop: (ev) => this.dropRows(), + }; + } } - }; - var cellCallbacks = { - 'mousedown': { 'select': this.cellMouseDown, - 'drag' : function(elem) { this.scheduleAssignCursor(elem, selector.NONE); }, - 'elemName': '.field:not(.column_name)', - 'source': this.scrollPane - }, - 'mousemove': { 'select': this.cellMouseMove, - 'source': document, - }, - 'mouseup': { 'select': this.cellMouseUp, - 'source': document, + })); + + // Trigger on column headings but not on the add column button + this.autoDispose(mouseDragMatchElem(this.viewPane, '.column_name.field:not(.mod-add-column)', (event, elem) => { + if (!ignoreEvent(event, elem)) { + if (!this.cellSelector.isSelected(elem, selector.COL)) { + this.colMouseDown(elem, event); + return { + onMove: (ev) => this.colMouseMove(ev), + onStop: (ev) => {}, + }; + } else { + this.styleColDragElements(elem, event); + return { + onMove: (ev) => this.dragCols(ev), + onStop: (ev) => this.dropCols(), + }; + } } - }; + })); - this.cellSelector.registerMouseHandlers(rowCallbacks, selector.ROW); - this.cellSelector.registerMouseHandlers(colCallbacks, selector.COL); - this.cellSelector.registerMouseHandlers(cellCallbacks, selector.CELL); -}; + this.autoDispose(mouseDragMatchElem(this.scrollPane, '.field:not(.column_name)', (event, elem) => { + if (!ignoreEvent(event, elem)) { + // TODO: should always enable + if (!this.cellSelector.isSelected(elem, selector.CELL)) { + this.cellMouseDown(elem, event); + return { + onMove: (ev) => this.cellMouseMove(ev), + onStop: (ev) => {}, + } + } else { // TODO: if true above, this will never come into play. + this.scheduleAssignCursor(elem, selector.NONE); + return { + onMove: (ev) => {}, + onStop: (ev) => { this.cellSelector.currentDragType(selector.NONE); }, + }; + } + } + })); +} // End of Selector stuff @@ -1487,7 +1498,7 @@ GridView.prototype.styleColDragElements = function(elem, event) { * 3) If the last col/row is in the select range, the indicator line should be clamped to the start of the * select range. **/ -GridView.prototype.dragRows = function(elem, event) { +GridView.prototype.dragRows = function(event) { var dropIndex = Math.min(this.getMousePosRow(event.pageY + this.scrollTop()), this.getLastDataRowIndex()); if (this.cellSelector.containsRow(dropIndex)) { @@ -1506,7 +1517,7 @@ GridView.prototype.dragRows = function(elem, event) { this.dragY(event.pageY); }; -GridView.prototype.dragCols = function(elem, event) { +GridView.prototype.dragCols = function(event) { let dropIndex = Math.min(this.getMousePosCol(event.pageX), this.viewSection.viewFields().peekLength - 1); if (this.cellSelector.containsCol(dropIndex)) { @@ -1539,6 +1550,7 @@ GridView.prototype.dragCols = function(elem, event) { GridView.prototype.dropRows = function() { var oldIndices = _.range(this.cellSelector.rowLower(), this.cellSelector.rowUpper() + 1); this.moveRows(oldIndices, this.cellSelector.row.dropIndex()); + this.cellSelector.currentDragType(selector.NONE); }; GridView.prototype.dropCols = function() { @@ -1552,6 +1564,7 @@ GridView.prototype.dropCols = function() { this.currentEditingColumnIndex(idx); } this._colClickTime = 0; + this.cellSelector.currentDragType(selector.NONE); }; // End of Dragging logic diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index dc8e0509..4e26d685 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -1262,7 +1262,7 @@ const cssViewContentPane = styled('div', ` flex: auto; display: flex; flex-direction: column; - overflow: hidden; + overflow: visible; position: relative; min-width: 240px; margin: 12px; diff --git a/app/client/components/RawDataPage.ts b/app/client/components/RawDataPage.ts index 476234ab..8e2bfa30 100644 --- a/app/client/components/RawDataPage.ts +++ b/app/client/components/RawDataPage.ts @@ -116,7 +116,7 @@ const cssOverlay = styled('div', ` inset: 0px; height: 100%; width: 100%; - padding: 32px 56px 0px 56px; + padding: 20px 56px 20px 56px; position: absolute; @media ${mediaSmall} { & { @@ -131,7 +131,6 @@ const cssSectionWrapper = styled('div', ` height: 100%; display: flex; flex-direction: column; - overflow: hidden; border-radius: 5px; border-bottom-left-radius: 0px; border-bottom-right-radius: 0px; diff --git a/app/client/components/SelectionSummary.ts b/app/client/components/SelectionSummary.ts new file mode 100644 index 00000000..28b7265e --- /dev/null +++ b/app/client/components/SelectionSummary.ts @@ -0,0 +1,339 @@ +import {CellSelector, COL, ROW} from 'app/client/components/CellSelector'; +import {copyToClipboard} from 'app/client/lib/copyToClipboard'; +import {Delay} from "app/client/lib/Delay"; +import {KoArray} from 'app/client/lib/koArray'; +import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; +import {UserError} from 'app/client/models/errors'; +import {ALL, RowsChanged, SortedRowSet} from "app/client/models/rowset"; +import {showTransientTooltip} from 'app/client/ui/tooltips'; +import {colors, isNarrowScreen, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {CellValue} from 'app/common/DocActions'; +import {isEmptyList, isListType, isRefListType} from "app/common/gristTypes"; +import {TableData} from "app/common/TableData"; +import {BaseFormatter} from 'app/common/ValueFormatter'; +import ko from 'knockout'; +import {Computed, Disposable, dom, makeTestId, Observable, styled, subscribe} from 'grainjs'; + +/** + * A beginning and end index for a range of columns or rows. + */ +interface Range { + begin: number; + end: number; +} + +/** + * A single part of the cell selection summary. + */ +interface SummaryPart { + /** Identifier for the summary part. */ + id: 'sum' | 'count' | 'dimensions'; + /** Label that's shown to the left of `value`. */ + label: string; + /** Value of the summary part. */ + value: string; + /** If true, displays a copy button on hover. Defaults to false. */ + clickToCopy?: boolean; +} + +const testId = makeTestId('test-selection-summary-'); + +// We can handle a million cells in under 60ms on a good laptop. Much beyond that, and we'll break +// selection with the bad performance. Instead, skip the counting and summing for too many cells. +const MAX_CELLS_TO_SCAN = 1_000_000; + +export class SelectionSummary extends Disposable { + private _colTotalCount = Computed.create(this, (use) => + use(use(this._viewFields).getObservable()).length); + + private _rowTotalCount = Computed.create(this, (use) => { + const rowIds = use(this._sortedRows.getKoArray().getObservable()); + const includesNewRow = (rowIds.length > 0 && rowIds[rowIds.length - 1] === 'new'); + return rowIds.length - (includesNewRow ? 1 : 0); + }); + + // In CellSelector, start and end are 0-based, inclusive, and not necessarily in order. + // It's not good for representing an empty range. Here, we convert ranges as [begin, end), + // with end >= begin. + private _rowRange = Computed.create(this, (use) => { + const type = use(this._cellSelector.currentSelectType); + if (type === COL) { + return {begin: 0, end: use(this._rowTotalCount)}; + } else { + const start = use(this._cellSelector.row.start); + const end = use(this._cellSelector.row.end); + return { + begin: Math.min(start, end), + end: Math.max(start, end) + 1, + }; + } + }); + + private _colRange = Computed.create(this, (use) => { + const type = use(this._cellSelector.currentSelectType); + if (type === ROW) { + return {begin: 0, end: use(this._colTotalCount)}; + } else { + const start = use(this._cellSelector.col.start); + const end = use(this._cellSelector.col.end); + return { + begin: Math.min(start, end), + end: Math.max(start, end) + 1, + }; + } + }); + + private _summary = Observable.create(this, []); + private _delayedRecalc = this.autoDispose(Delay.create()); + + constructor( + private _cellSelector: CellSelector, + private _tableData: TableData, + private _sortedRows: SortedRowSet, + private _viewFields: ko.Computed>, + ) { + super(); + + this.autoDispose(this._sortedRows.getKoArray().subscribe(this._onSpliceChange, this, 'spliceChange')); + const onRowNotify = this._onRowNotify.bind(this); + this._sortedRows.on('rowNotify', onRowNotify); + this.onDispose(() => this._sortedRows.off('rowNotify', onRowNotify)); + this.autoDispose(subscribe(this._rowRange, this._colRange, + () => this._scheduleRecalc())); + this.autoDispose(isNarrowScreenObs().addListener((isNarrow) => { + if (isNarrow) { return; } + // No calculations occur while the screen is narrow, so we need to schedule one. + this._scheduleRecalc(); + })); + } + + public buildDom() { + return cssSummary( + dom.forEach(this._summary, ({id, label, value, clickToCopy}) => + cssSummaryPart( + label ? dom('span', cssLabelText(label), cssCopyIcon('Copy')) : null, + value, + cssSummaryPart.cls('-copyable', Boolean(clickToCopy)), + (clickToCopy ? dom.on('click', (ev, elem) => doCopy(value, elem)) : null), + testId(id), + ) + ), + ); + } + + private _onSpliceChange(splice: {start: number}) { + const rowRange = this._rowRange.get(); + const rowCount = rowRange.end - rowRange.begin; + if (rowCount === 1) { return; } + if (splice.start >= rowRange.end) { return; } + // We could be smart here and only recalculate when the splice affects our selection. But for + // that to make sense, the selection itself needs to be smart. Currently, the selection is + // lost whenever the cursor is affected. For example, when you have a selection and another + // user adds/removes columns or rows before the selection, the selection won't be shifted + // with the cursor, and will instead be cleared. Since we can't always rely on the selection + // being there, we'll err on the safe side and always schedule a recalc. + this._scheduleRecalc(); + } + + private _onRowNotify(rows: RowsChanged) { + const rowRange = this._rowRange.get(); + if (rows === ALL) { + this._scheduleRecalc(); + } else { + const rowArray = this._sortedRows.getKoArray().peek(); + const rowIdSet = new Set(rows); + for (let r = rowRange.begin; r < rowRange.end; r++) { + if (rowIdSet.has(rowArray[r])) { + this._scheduleRecalc(); + break; + } + } + } + } + + /** + * Schedules a re-calculation to occur in the immediate future. + * + * May be called repeatedly, but only a single re-calculation will be scheduled, to + * avoid queueing unnecessary amounts of work. + */ + private _scheduleRecalc() { + // `_recalc` may take a non-trivial amount of time, so we defer until the stack is clear. + this._delayedRecalc.schedule(0, () => this._recalc()); + } + + private _recalc() { + const rowRange = this._rowRange.get(); + const colRange = this._colRange.get(); + let rowCount = rowRange.end - rowRange.begin; + let colCount = colRange.end - colRange.begin; + const cellCount = rowCount * colCount; + const summary: SummaryPart[] = []; + // Do nothing on narrow screens, because we haven't come up with a place to render sum anyway. + if (cellCount > 1 && !isNarrowScreen()) { + if (cellCount <= MAX_CELLS_TO_SCAN) { + const rowArray = this._sortedRows.getKoArray().peek(); + const fields = this._viewFields.peek().peek(); + let countNumeric = 0; + let countNonEmpty = 0; + let sum = 0; + let sumFormatter: BaseFormatter|null = null; + const rowIndices: number[] = []; + for (let r = rowRange.begin; r < rowRange.end; r++) { + const rowId = rowArray[r]; + if (rowId === undefined || rowId === 'new') { + // We can run into this whenever the selection gets out of sync due to external + // changes, like another user removing some rows. For now, we'll skip rows that are + // still selected and no longer exist, but the real TODO is to better update the + // selection so that it doesn't have out-of-date and invalid ranges. + rowCount -= 1; + continue; + } + rowIndices.push(this._tableData.getRowIdIndex(rowId)!); + } + for (let c = colRange.begin; c < colRange.end; c++) { + const field = fields[c]; + if (field === undefined) { + // Like with rows (see comment above), we need to watch out for out-of-date ranges. + colCount -= 1; + continue; + } + const col = fields[c].column.peek(); + const displayCol = fields[c].displayColModel.peek(); + const colType = col.type.peek(); + const visibleColType = fields[c].visibleColModel.peek().type.peek(); + const effectiveColType = visibleColType ?? colType; + const displayColId = displayCol.colId.peek(); + // Note: we get values from the display column so that reference columns displaying + // numbers are included in the computed sum. Unfortunately, that also means we can't + // show a count of non-empty references. For now, that's a trade-off we'll have to make, + // but in the future it should be possible to allow showing multiple summary parts with + // some level of configurability. + const values = this._tableData.getColValues(displayColId); + if (!values) { + throw new UserError(`Invalid column ${this._tableData.tableId}.${displayColId}`); + } + const isNumeric = ['Numeric', 'Int', 'Any'].includes(effectiveColType); + const isEmpty: undefined | ((value: CellValue) => boolean) = ( + colType.startsWith('Ref:') && !visibleColType ? value => (value === 0) : + isRefListType(colType) || isListType(effectiveColType) ? isEmptyList : + undefined + ); + // The loops below are optimized, minimizing the amount of work done per row. For + // example, column values are retrieved in bulk above instead of once per row. In one + // unscientific test, they take 30-60ms per million numeric cells. + // + // TODO: Add a benchmark test suite that automates checking for performance regressions. + if (isNumeric) { + if (!sumFormatter) { + sumFormatter = fields[c].formatter.peek(); + } + for (const i of rowIndices) { + const value = values[i]; + if (typeof value === 'number') { + countNumeric++; + sum += value; + } else if (value !== null && value !== undefined && value !== '' && !isEmpty?.(value)) { + countNonEmpty++; + } + } + } else { + for (const i of rowIndices) { + const value = values[i]; + if (value !== null && value !== undefined && value !== '' && !isEmpty?.(value)) { + countNonEmpty++; + } + } + } + } + + if (countNumeric > 0) { + const sumValue = sumFormatter ? sumFormatter.formatAny(sum) : String(sum); + summary.push({id: 'sum', label: 'Sum ', value: sumValue, clickToCopy: true}); + } else { + summary.push({id: 'count', label: 'Count ', value: String(countNonEmpty), clickToCopy: true}); + } + } + summary.push({id: 'dimensions', label: '', value: `${rowCount}⨯${colCount}`}); + } + this._summary.set(summary); + } +} + +async function doCopy(value: string, elem: Element) { + await copyToClipboard(value); + showTransientTooltip(elem, 'Copied to clipboard', {key: 'copy-selection-summary'}); +} + +const cssSummary = styled('div', ` + position: absolute; + bottom: -18px; + height: 18px; + line-height: 18px; + display: flex; + column-gap: 8px; + width: 100%; + justify-content: end; + color: ${theme.text}; + /* Small hack: override the backdrop when viewing raw data to improve visibility. */ + background-color: ${theme.mainPanelBg}; + font-family: ${vars.fontFamilyData}; + + @media print { + & { + display: none; + } + } +`); + +// Note: the use of an extra element for the background is to set its opacity, to make it a bit +// lighter (or darker, in dark-mode) than actual mediumGrey, without defining a special color. +const cssSummaryPart = styled('div', ` + padding: 0 8px; + border-radius: 4px; + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-top: none; + z-index: 100; + position: relative; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &-copyable:hover { + cursor: pointer; + } + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: ${colors.mediumGrey}; + opacity: 0.8; + z-index: -1; + } +`); + +const cssLabelText = styled('span', ` + font-size: ${vars.xsmallFontSize}; + text-transform: uppercase; + position: relative; + margin-right: 4px; + .${cssSummaryPart.className}-copyable:hover & { + visibility: hidden; + } +`); + +const cssCopyIcon = styled(icon, ` + position: absolute; + top: 0; + margin: 1px 0 0 4px; + --icon-color: ${theme.controlFg}; + display: none; + .${cssSummaryPart.className}-copyable:hover & { + display: block; + } +`); diff --git a/app/client/components/Selector.js b/app/client/components/Selector.js deleted file mode 100644 index 96471bc6..00000000 --- a/app/client/components/Selector.js +++ /dev/null @@ -1,291 +0,0 @@ -/** - * Selector takes care of attaching callbacks to the relevant mouse events on the given view. - * Selection and dragging/dropping consists of 3 phases: mouse down -> mouse move -> mouse up - * The Selector class is purposefully lightweight because different views might have - * different select/drag/drop behavior. Most of the work is done in the callbacks - * provided to the Selector class. - * - * Usage: - Selectors are instantiated with a view. - @param{view}: The view containing the selectable/draggable elements - * Views must also supply the Selector class with mousedown/mousemove/mouseup callbacks and - * the associated element's that listen for the mouse events. - * through registerMouseHandlers. - */ - -/* globals document */ - -var ko = require('knockout'); -var _ = require('underscore'); -var dispose = require('../lib/dispose'); -var gutil = require('app/common/gutil'); -const {isWin} = require('app/client/lib/browserInfo') - -var ROW = 'row'; -var COL = 'col'; -var CELL = 'cell'; -var NONE = ''; -var SELECT = 'select'; -var DRAG = 'drag'; - -exports.ROW = ROW; -exports.COL = COL; -exports.CELL = CELL; -exports.NONE = NONE; - -/** - * @param {Object} view - * @param {Object} opt - * @param {function} opt.isDisabled - Is this selector disabled? Allows caller to specify - * conditions for temporarily disabling capturing of mouse events. - */ -function Selector(view, opt) { - this.view = view; - // TODO: There should be a better way to ensure that select/drag doesnt happen when clicking - // on these things. Also, these classes should not be in the generic Selector class. - // TODO: get rid of the Selector class entirely and make this a Cell/GridSelector class specifically - // for GridView(and its derived views). - this.exemptClasses = [ - 'glyphicon-pencil', - 'ui-resizable-handle', - 'dropdown-toggle', - ]; - - opt = opt || {}; - - this.isDisabled = opt.isDisabled || _.constant(false); - this.isWindows = isWin(); -} - -/** - * Register mouse callbacks to various sources. - * @param {Object} callbacks - an object containing mousedown/mouseup/mouseup functions - * for selecting and dragging, along with with the source string name and target element - * string name to which the mouse events must listen on. - * @param {string} handlerName - string name of the kind of element that the mouse callbacks - * are acting on. - * handlerName is used to deduce what kind of element is triggering the mouse callbacks - * The alternative is to look at triggering DOM element's css classes which is more hacky. - */ -Selector.prototype.registerMouseHandlers = function(callbacks, handlerName) { - this.setCallbackDefaults(callbacks); - var self = this; - - this.view.onEvent(callbacks.mousedown.source, 'mousedown', callbacks.mousedown.elemName, - function(elem, event) { - if (self.isExemptMouseTarget(event) || event.button !== 0 || self.isDisabled()) { - return true; // Do nothing if the mouse event if exempt or not a left click - } - - if (!self.isSelected(elem, handlerName) && !callbacks.disableSelect()) { - self.applyCallbacks(SELECT, callbacks, elem, event); - } else if (!callbacks.disableDrag()) { - self.applyCallbacks(DRAG, callbacks, elem, event); - } - }); - -}; - -Selector.prototype.isExemptMouseTarget = function(event) { - var cl = event.target.classList; - return _.some(this.exemptClasses, cl.contains.bind(cl)); -}; - -Selector.prototype.setCallbackDefaults = function(callbacks) { - _.defaults(callbacks, {'mousedown': {}, 'mousemove': {}, 'mouseup': {}, - 'disableDrag': _.constant(false), 'disableSelect': _.constant(false)} - ); - _.defaults(callbacks.mousedown, {'select': _.noop, 'drag': _.noop, 'elemName': null, - 'source': null}); - _.defaults(callbacks.mousemove, {'select': _.noop, 'drag': _.noop, 'elemName': null, - 'source': document}); - _.defaults(callbacks.mouseup, {'select': _.noop, 'drag': _.noop, 'elemName': null, - 'source': document}); -}; - -/** - * Applies the drag or select callback for mousedown and then registers - * the appropriate mousemove and mouseup callbacks. We only register mousemove/mouseup - * after seeing a mousedown event so that we don't have to constantly listen for - * mousemove/mouseup. - * @param {String} dragOrSelect - string that is either 'drag' or 'select' which denotes - * which mouse methods to apply on mouse events. - * @param {Object} callbacks - an object containing mousedown/mouseup/mouseup functions - * for selecting and dragging, along with with the source string name and target element - * string name to which the mouse events must listen on. - */ -Selector.prototype.applyCallbacks = function(dragOrSelect, callbacks, mouseDownElem, mouseDownEvent) { - console.assert(dragOrSelect === DRAG || dragOrSelect === SELECT); - var self = this; - - callbacks.mousedown[dragOrSelect].call(this.view, mouseDownElem, mouseDownEvent); - this.view.onEvent(callbacks.mousemove.source, 'mousemove', function(elem, event) { - // On windows, when browser doesn't have focus, the first click produces artificial mousemove - // event. Fortunately, the mousemove event has the same coordinates as the mousedown event, so - // we will ignore it. - // Related issues: - // https://bugs.chromium.org/p/chromium/issues/detail?id=161464 - // https://bugs.chromium.org/p/chromium/issues/detail?id=721341#c34 - if (self.isWindows) { - if (event.screenX === mouseDownEvent.screenX && event.screenY === mouseDownEvent.screenY) { - return; - } - } - callbacks.mousemove[dragOrSelect].call(self.view, elem, event); - }); - - this.view.onEvent(callbacks.mouseup.source, 'mouseup', function(elem, event) { - callbacks.mouseup[dragOrSelect].call(self.view, elem, event); - self.view.clearEvent(callbacks.mousemove.source, 'mousemove'); - self.view.clearEvent(callbacks.mouseup.source, 'mouseup'); - if (dragOrSelect === DRAG) self.currentDragType(NONE); - }); -}; - -// =========================================================================== -// CELL SELECTOR - -function CellSelector(view, opt) { - Selector.call(this, view, opt); - - // row or col.start denotes the anchor/initial index of the select range. - // start is not necessarily smaller than end. - // IE: clicking on col 10 and dragging until the mouse is on col 5 will yield: start = 10, end = 5 - this.row = { - start: ko.observable(0), - end: ko.observable(0), - linePos: ko.observable('0px'), - dropIndex: ko.observable(-1), - }; - this.col = { - start: ko.observable(0), - end: ko.observable(0), - linePos: ko.observable('0px'), - dropIndex: ko.observable(-1), - }; - this.currentSelectType = ko.observable(NONE); - this.currentDragType = ko.observable(NONE); - - this.autoDispose(this.view.cursor.rowIndex.subscribeInit(function(rowIndex) { - this.setToCursor(); - }, this)); - this.autoDispose(this.view.cursor.fieldIndex.subscribeInit(function(colIndex) { - this.setToCursor(); - }, this)); -} - -dispose.makeDisposable(CellSelector); -_.extend(CellSelector.prototype, Selector.prototype); - -CellSelector.prototype.setToCursor = function(elemType) { - // Must check that the view contains cursor.rowIndex/cursor.fieldIndex - // in case it has changed. - if (this.view.cursor.rowIndex) { - this.row.start(this.view.cursor.rowIndex()); - this.row.end(this.view.cursor.rowIndex()); - } - if (this.view.cursor.fieldIndex) { - this.col.start(this.view.cursor.fieldIndex()); - this.col.end(this.view.cursor.fieldIndex()); - } - this.currentSelectType(elemType || NONE); -}; - -CellSelector.prototype.containsCell = function(rowIndex, colIndex) { - return this.containsCol(colIndex) && this.containsRow(rowIndex); -}; - -CellSelector.prototype.containsRow = function(rowIndex) { - return gutil.between(rowIndex, this.row.start(), this.row.end()); -}; - -CellSelector.prototype.containsCol = function(colIndex) { - return gutil.between(colIndex, this.col.start(), this.col.end()); -}; - -CellSelector.prototype.isSelected = function(elem, handlerName) { - if (handlerName !== this.currentSelectType()) return false; - - // TODO: this only works with view: GridView. - // But it seems like we only ever use selectors with gridview anyway - let row = this.view.domToRowModel(elem, handlerName); - let col = this.view.domToColModel(elem, handlerName); - switch (handlerName) { - case ROW: - return this.containsRow(row._index()); - case COL: - return this.containsCol(col._index()); - case CELL: - return this.containsCell(row._index(), col._index()); - default: - console.error('Given element is not a row, cell or column'); - return false; - } -}; - -CellSelector.prototype.isRowSelected = function(rowIndex) { - return this.isCurrentSelectType(COL) || this.containsRow(rowIndex); -}; - -CellSelector.prototype.isColSelected = function(colIndex) { - return this.isCurrentSelectType(ROW) || this.containsCol(colIndex); -}; - -CellSelector.prototype.isCellSelected = function(rowIndex, colIndex) { - return this.isColSelected(colIndex) && this.isRowSelected(rowIndex); -}; - -CellSelector.prototype.onlyCellSelected = function(rowIndex, colIndex) { - return (this.row.start() === rowIndex && this.row.end() === rowIndex) && - (this.col.start() === colIndex && this.col.end() === colIndex); -}; - -CellSelector.prototype.isCurrentSelectType = function(elemType) { - return this._isCurrentType(this.currentSelectType(), elemType); -}; - -CellSelector.prototype.isCurrentDragType = function(elemType) { - return this._isCurrentType(this.currentDragType(), elemType); -}; - -CellSelector.prototype._isCurrentType = function(currentType, elemType) { - console.assert([ROW, COL, CELL, NONE].indexOf(elemType) !== -1); - return currentType === elemType; -}; - -CellSelector.prototype.colLower = function() { - return Math.min(this.col.start(), this.col.end()); -}; - -CellSelector.prototype.colUpper = function() { - return Math.max(this.col.start(), this.col.end()); -}; - -CellSelector.prototype.rowLower = function() { - return Math.min(this.row.start(), this.row.end()); -}; - -CellSelector.prototype.rowUpper = function() { - return Math.max(this.row.start(), this.row.end()); -}; - -CellSelector.prototype.colCount = function() { - return this.colUpper() - this.colLower() + 1; -}; - -CellSelector.prototype.rowCount = function() { - return this.rowUpper() - this.rowLower() + 1; -}; - -CellSelector.prototype.selectArea = function(rowStartIdx, colStartIdx, rowEndIdx, colEndIdx) { - this.row.start(rowStartIdx); - this.col.start(colStartIdx); - this.row.end(rowEndIdx); - this.col.end(colEndIdx); - // Only select the area if it's not a single cell - if (this.colCount() > 1 || this.rowCount() > 1) { - this.currentSelectType(CELL); - } -}; - -exports.CellSelector = CellSelector; diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts index 3eec3dc9..4e543674 100644 --- a/app/client/components/ViewLayout.ts +++ b/app/client/components/ViewLayout.ts @@ -15,7 +15,7 @@ import {reportError} from 'app/client/models/errors'; import {filterBar} from 'app/client/ui/FilterBar'; import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu'; import {buildWidgetTitle} from 'app/client/ui/WidgetTitle'; -import {mediaSmall, testId, theme} from 'app/client/ui2018/cssVars'; +import {isNarrowScreenObs, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {mod} from 'app/common/gutil'; @@ -309,7 +309,7 @@ export function buildViewSectionDom(options: { )), dom.maybe((use) => use(vs.activeFilterBar) || use(vs.isRaw) && use(vs.activeFilters).length, () => dom.create(filterBar, vs)), - dom.maybe(vs.viewInstance, (viewInstance) => + dom.maybe(vs.viewInstance, (viewInstance) => [ dom('div.view_data_pane_container.flexvbox', cssResizing.cls('', isResizing), dom.maybe(viewInstance.disableEditing, () => @@ -319,9 +319,10 @@ export function buildViewSectionDom(options: { dom('div.viewsection_truncated', 'Not all data is shown') ), dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)), - viewInstance.viewPane - ) - ), + viewInstance.viewPane, + ), + dom.maybe(use => !use(isNarrowScreenObs()), () => viewInstance.selectionSummary?.buildDom()), + ]), dom.on('mousedown', () => { viewModel?.activeSectionId(sectionRowId); }), ); } diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index 8c7ac1a8..64321d55 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -32,6 +32,7 @@ declare module "app/client/components/BaseView" { import {Cursor, CursorPos} from 'app/client/components/Cursor'; import {GristDoc} from 'app/client/components/GristDoc'; + import {SelectionSummary} from 'app/client/components/SelectionSummary'; import {Disposable} from 'app/client/lib/dispose'; import BaseRowModel from "app/client/models/BaseRowModel"; import {DataRowModel} from 'app/client/models/DataRowModel'; @@ -61,6 +62,7 @@ declare module "app/client/components/BaseView" { public disableEditing: ko.Computed; public isTruncated: ko.Observable; public tableModel: DataTableModel; + public selectionSummary?: SelectionSummary; constructor(gristDoc: GristDoc, viewSectionModel: any, options?: {addNewRow?: boolean, isPreview?: boolean}); public setCursorPos(cursorPos: CursorPos): void; diff --git a/app/client/ui/mouseDrag.ts b/app/client/ui/mouseDrag.ts index 34a39404..455bf4d6 100644 --- a/app/client/ui/mouseDrag.ts +++ b/app/client/ui/mouseDrag.ts @@ -30,6 +30,12 @@ export function mouseDrag(onStart: MouseDragStart): DomElementMethod { return (elem) => { mouseDragElem(elem, onStart); }; } +// Same as mouseDragElem, but listens for mousedown on descendants of elem that match selector. +export function mouseDragMatchElem(elem: HTMLElement, selector: string, onStart: MouseDragStart): IDisposable { + return dom.onMatchElem(elem, selector, 'mousedown', + (ev, el) => _startDragging(ev as MouseEvent, el as HTMLElement, onStart)); +} + function _startDragging(startEv: MouseEvent, elem: HTMLElement, onStart: MouseDragStart) { const dragHandler = onStart(startEv, elem); if (dragHandler) { diff --git a/app/common/TableData.ts b/app/common/TableData.ts index 73c4f65e..d4b9c614 100644 --- a/app/common/TableData.ts +++ b/app/common/TableData.ts @@ -169,18 +169,24 @@ export class TableData extends ActionDispatcher implements SkippableRows { return this._rowMap.has(rowId); } + /** + * Returns the index of the given rowId, if it exists, in the same unstable order that's + * returned by getRowIds() and getColValues(). + */ + public getRowIdIndex(rowId: UIRowId): number|undefined { + return this._rowMap.get(rowId as number); + } + /** * Given a column name, returns a function that takes a rowId and returns the value for that * column of that row. The returned function is faster than getValue() calls. */ public getRowPropFunc(colId: string): UIRowFunc { + const colData = this._columns.get(colId); + if (!colData) { return () => undefined; } + const values = colData.values; const rowMap = this._rowMap; - return (rowId: UIRowId) => { - const colData = this._columns.get(colId); - if (!colData) { return undefined; } - const values = colData.values; - return values[rowMap.get(rowId as number)!]; - }; + return (rowId: UIRowId) => values[rowMap.get(rowId as number)!]; } // By default, no rows are skippable, all are kept. diff --git a/test/fixtures/docs/SelectionSummary.grist b/test/fixtures/docs/SelectionSummary.grist new file mode 100644 index 00000000..9d3168dd Binary files /dev/null and b/test/fixtures/docs/SelectionSummary.grist differ diff --git a/test/nbrowser/SelectionSummary.ts b/test/nbrowser/SelectionSummary.ts new file mode 100644 index 00000000..264f060b --- /dev/null +++ b/test/nbrowser/SelectionSummary.ts @@ -0,0 +1,211 @@ +import {assert, driver, Key, WebElement} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {setupTestSuite} from 'test/nbrowser/testUtils'; + +interface CellPosition { + /** 0-based column index. */ + col: number; + /** 0-based row index. */ + row: number; +} + +interface SelectionSummary { + dimensions: string; + count: number | null; + sum: string | null; +} + +describe('SelectionSummary', function () { + this.timeout(20000); + const cleanup = setupTestSuite(); + + before(async function() { + const session = await gu.session().personalSite.login(); + await session.tempDoc(cleanup, 'SelectionSummary.grist'); + }); + + async function assertSelectionSummary(summary: SelectionSummary | null) { + if (!summary) { + assert.isFalse(await driver.find('.test-selection-summary-dimensions').isPresent()); + assert.isFalse(await driver.find('.test-selection-summary-count').isPresent()); + assert.isFalse(await driver.find('.test-selection-summary-sum').isPresent()); + return; + } + + const {dimensions, count, sum} = summary; + await gu.waitToPass(async () => assert.equal( + await driver.find('.test-selection-summary-dimensions').getText(), + dimensions + ), 500); + if (count === null) { + assert.isFalse(await driver.find('.test-selection-summary-count').isPresent()); + } else { + await gu.waitToPass(async () => assert.equal( + await driver.find('.test-selection-summary-count').getText(), + `COUNT ${count}` + ), 500); + } + if (sum === null) { + assert.isFalse(await driver.find('.test-selection-summary-sum').isPresent()); + } else { + await gu.waitToPass(async () => assert.equal( + await driver.find('.test-selection-summary-sum').getText(), + `SUM ${sum}` + ), 500); + } + } + + function shiftClick(el: WebElement) { + return driver.withActions((actions) => actions.keyDown(Key.SHIFT).click(el).keyUp(Key.SHIFT)); + } + + async function selectAndAssert(start: CellPosition, end: CellPosition, summary: SelectionSummary | null) { + const {col: startCol, row: startRow} = start; + await gu.getCell(startCol, startRow + 1).click(); + const {col: endCol, row: endRow} = end; + await shiftClick(await gu.getCell(endCol, endRow + 1)); + await assertSelectionSummary(summary); + } + + it('does not display anything if only a single cell is selected', async function () { + for (const [col, row] of [[0, 1], [2, 3]]) { + await gu.getCell(col, row).click(); + await assertSelectionSummary(null); + } + }); + + it('displays sum if the selection contains numbers', async function () { + await selectAndAssert({col: 0, row: 0}, {col: 0, row: 6}, { + dimensions: '7⨯1', + count: null, + sum: '$135,692,590', + }); + await selectAndAssert({col: 0, row: 3}, {col: 0, row: 6}, { + dimensions: '4⨯1', + count: null, + sum: '$135,679,011', + }); + + await selectAndAssert({col: 4, row: 0}, {col: 4, row: 6}, { + dimensions: '7⨯1', + count: null, + sum: '135692590', + }); + await selectAndAssert({col: 0, row: 0}, {col: 4, row: 6}, { + dimensions: '7⨯5', + count: null, + sum: '$271,385,168.02', + }); + }); + + it('uses formatter of the first (leftmost) numeric column', async function () { + // Column 0 is U.S. currency, while column 1 is just a plain decimal number. + await selectAndAssert({col: 0, row: 0}, {col: 1, row: 6}, { + dimensions: '7⨯2', + count: null, + sum: '$135,692,578.02', + }); + await selectAndAssert({col: 1, row: 0}, {col: 1, row: 6}, { + dimensions: '7⨯1', + count: null, + sum: '-11.98', + }); + // The entire selection (spanning 6 columns) uses the formatter of column 0. + await selectAndAssert({col: 0, row: 0}, {col: 5, row: 6}, { + dimensions: '7⨯6', + count: null, + sum: '$271,385,156.04', + }); + }); + + it("displays count if the selection doesn't contain numbers", async function () { + await selectAndAssert({col: 2, row: 0}, {col: 2, row: 6}, { + dimensions: '7⨯1', + count: 5, + sum: null, + }); + await selectAndAssert({col: 2, row: 3}, {col: 2, row: 6}, { + dimensions: '4⨯1', + count: 2, + sum: null, + }); + await selectAndAssert({col: 2, row: 0}, {col: 3, row: 5}, { + dimensions: '6⨯2', + count: 11, + sum: null, + }); + + // Scroll horizontally to the end of the table. + await gu.sendKeys(Key.END); + + await selectAndAssert({col: 7, row: 0}, {col: 10, row: 4}, { + dimensions: '5⨯4', + count: 7, + sum: null, + }); + await selectAndAssert({col: 10, row: 0}, {col: 12, row: 6}, { + dimensions: '7⨯3', + count: 5, + sum: null, + }); + }); + + it('uses the show column of reference columns for computations', async function () { + // Column 6 is a Reference column pointing to column 0. + await gu.sendKeys(Key.HOME); + await selectAndAssert({col: 6, row: 0}, {col: 6, row: 6}, { + dimensions: '7⨯1', + count: null, + sum: '-$123,456', + }); + + // Column 7 is a Reference List column pointing to column 0. At this time, it + // only displays counts (but flattening sums also seems like intuitive behavior). + await gu.sendKeys(Key.END); + await selectAndAssert({col: 7, row: 0}, {col: 7, row: 6}, { + dimensions: '7⨯1', + count: 2, + sum: null, + }); + }); + + it('updates whenever the selection changes', async function () { + // Scroll horizontally to the beginning of the table. + await gu.sendKeys(Key.HOME); + + // Select a region of the table. + await selectAndAssert({col: 0, row: 2}, {col: 0, row: 6}, { + dimensions: '5⨯1', + count: null, + sum: '$135,691,356', + }); + + // Without de-selecting, use keyboard shortcuts to grow the selection to the right. + await gu.sendKeys(Key.chord(Key.SHIFT, Key.ARROW_RIGHT)); + + // Check that the selection summary was updated. + await assertSelectionSummary({ + dimensions: '5⨯2', + count: null, + sum: '$135,691,368.5', + }); + }); + + it('displays correct sum when all rows/columns are selected', async function () { + await driver.find(".gridview_data_corner_overlay").click(); + await assertSelectionSummary({ + dimensions: '7⨯14', + count: null, + sum: '$271,261,700.04', + }); + }); + + describe('on narrow screens', function() { + gu.narrowScreen(); + + it('is not visible', async function() { + await assertSelectionSummary(null); + await selectAndAssert({col: 0, row: 0}, {col: 0, row: 6}, null); + }); + }); + });