mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Add cell selection summary
Summary: Adds a cell selection summary to grid view that shows either a count or sum of all the selected values. Implementation was done by Dmitry. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Subscribers: paulfitz, dsagal, jarek Differential Revision: https://phab.getgrist.com/D3630
This commit is contained in:
parent
433e1ecfc2
commit
364610c69d
157
app/client/components/CellSelector.ts
Normal file
157
app/client/components/CellSelector.ts
Normal file
@ -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<ElemType>(NONE);
|
||||||
|
public currentDragType = ko.observable<ElemType>(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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
/* globals alert, document, $ */
|
/* globals alert, $ */
|
||||||
|
|
||||||
const _ = require('underscore');
|
const _ = require('underscore');
|
||||||
const ko = require('knockout');
|
const ko = require('knockout');
|
||||||
@ -19,8 +19,9 @@ const commands = require('./commands');
|
|||||||
const viewCommon = require('./viewCommon');
|
const viewCommon = require('./viewCommon');
|
||||||
const Base = require('./Base');
|
const Base = require('./Base');
|
||||||
const BaseView = require('./BaseView');
|
const BaseView = require('./BaseView');
|
||||||
const selector = require('./Selector');
|
const selector = require('./CellSelector');
|
||||||
const {CopySelection} = require('./CopySelection');
|
const {CopySelection} = require('./CopySelection');
|
||||||
|
const {SelectionSummary} = require('./SelectionSummary');
|
||||||
const koUtil = require('app/client/lib/koUtil');
|
const koUtil = require('app/client/lib/koUtil');
|
||||||
const convert = require('color-convert');
|
const convert = require('color-convert');
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ const {setPopupToCreateDom} = require('popweasel');
|
|||||||
const {CellContextMenu} = require('app/client/ui/CellContextMenu');
|
const {CellContextMenu} = require('app/client/ui/CellContextMenu');
|
||||||
const {testId} = require('app/client/ui2018/cssVars');
|
const {testId} = require('app/client/ui2018/cssVars');
|
||||||
const {contextMenu} = require('app/client/ui/contextMenu');
|
const {contextMenu} = require('app/client/ui/contextMenu');
|
||||||
|
const {mouseDragMatchElem} = require('app/client/ui/mouseDrag');
|
||||||
const {menuToggle} = require('app/client/ui/MenuToggle');
|
const {menuToggle} = require('app/client/ui/MenuToggle');
|
||||||
const {showTooltip} = require('app/client/ui/tooltips');
|
const {showTooltip} = require('app/client/ui/tooltips');
|
||||||
const {parsePasteForView} = require("./BaseView2");
|
const {parsePasteForView} = require("./BaseView2");
|
||||||
@ -79,11 +81,9 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
|||||||
this.scrollTop = ko.observable(0);
|
this.scrollTop = ko.observable(0);
|
||||||
this.isScrolledTop = this.autoDispose(ko.computed(() => this.scrollTop() > 0));
|
this.isScrolledTop = this.autoDispose(ko.computed(() => this.scrollTop() > 0));
|
||||||
|
|
||||||
this.cellSelector = this.autoDispose(selector.CellSelector.create(this, {
|
this.cellSelector = selector.CellSelector.create(this, this);
|
||||||
// This is a bit of a hack to prevent dragging when there's an open column menu
|
this.selectionSummary = SelectionSummary.create(this,
|
||||||
// TODO: disable dragging when there is an open cell context menu as well
|
this.cellSelector, this.tableModel.tableData, this.sortedRows, this.viewSection.viewFields);
|
||||||
isDisabled: () => Boolean(!this.ctxMenuHolder.isEmpty())
|
|
||||||
}));
|
|
||||||
this.colMenuTargets = {}; // Reference from column ref to its menu target dom
|
this.colMenuTargets = {}; // Reference from column ref to its menu target dom
|
||||||
|
|
||||||
// Cache of column right offsets, used to determine the col select range
|
// 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() };
|
return { 'width' : this.viewSection.viewFields().at(colIndex).widthPx() };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// TODO: for now lets just assume you are clicking on a .field, .row, or .column
|
// TODO: for now lets just assume you are clicking on a .field, .row, or .column
|
||||||
GridView.prototype.domToRowModel = function(elem, elemType) {
|
GridView.prototype.domToRowModel = function(elem, elemType) {
|
||||||
switch (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));
|
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),
|
var currentCol = Math.min(this.getMousePosCol(event.pageX),
|
||||||
this.viewSection.viewFields().peekLength - 1);
|
this.viewSection.viewFields().peekLength - 1);
|
||||||
this.cellSelector.col.end(currentCol);
|
this.cellSelector.col.end(currentCol);
|
||||||
};
|
};
|
||||||
|
|
||||||
GridView.prototype.cellMouseMove = function(elem, event, extra) {
|
GridView.prototype.cellMouseMove = function(event) {
|
||||||
this.colMouseMove(elem, event);
|
this.colMouseMove(event);
|
||||||
this.rowMouseMove(elem, event);
|
this.rowMouseMove(event);
|
||||||
// Maintain single cells cannot be selected invariant
|
// Maintain single cells cannot be selected invariant
|
||||||
if (this.cellSelector.onlyCellSelected(this.cursor.rowIndex(), this.cursor.fieldIndex())) {
|
if (this.cellSelector.onlyCellSelected(this.cursor.rowIndex(), this.cursor.fieldIndex())) {
|
||||||
this.cellSelector.currentSelectType(selector.NONE);
|
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
|
// 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
|
// dom elements exist so we attach the selector handlers separately from instantiation
|
||||||
GridView.prototype.attachSelectorHandlers = function () {
|
GridView.prototype.attachSelectorHandlers = function () {
|
||||||
// We attach mousemove and mouseup to document so that selecting and drag/dropping
|
const ignoreEvent = (event, elem) => (
|
||||||
// work even if the mouse leaves the view pane: http://news.qooxdoo.org/mouse-capturing
|
event.button !== 0 ||
|
||||||
// Mousemove/up events fire to document even if the mouse leaves the browser window.
|
event.target.classList.contains('ui-resizable-handle') ||
|
||||||
var rowCallbacks = {
|
// This is a bit of a hack to prevent dragging when there's an open column menu
|
||||||
'disableDrag': this.viewSection.disableDragRows,
|
// TODO: disable dragging when there is an open cell context menu as well
|
||||||
'mousedown': { 'select': this.rowMouseDown,
|
!this.ctxMenuHolder.isEmpty()
|
||||||
'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,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.cellSelector.registerMouseHandlers(rowCallbacks, selector.ROW);
|
this.autoDispose(mouseDragMatchElem(this.viewPane, '.gridview_data_row_num', (event, elem) => {
|
||||||
this.cellSelector.registerMouseHandlers(colCallbacks, selector.COL);
|
if (!ignoreEvent(event, elem)) {
|
||||||
this.cellSelector.registerMouseHandlers(cellCallbacks, selector.CELL);
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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.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
|
// 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
|
* 3) If the last col/row is in the select range, the indicator line should be clamped to the start of the
|
||||||
* select range.
|
* select range.
|
||||||
**/
|
**/
|
||||||
GridView.prototype.dragRows = function(elem, event) {
|
GridView.prototype.dragRows = function(event) {
|
||||||
var dropIndex = Math.min(this.getMousePosRow(event.pageY + this.scrollTop()),
|
var dropIndex = Math.min(this.getMousePosRow(event.pageY + this.scrollTop()),
|
||||||
this.getLastDataRowIndex());
|
this.getLastDataRowIndex());
|
||||||
if (this.cellSelector.containsRow(dropIndex)) {
|
if (this.cellSelector.containsRow(dropIndex)) {
|
||||||
@ -1506,7 +1517,7 @@ GridView.prototype.dragRows = function(elem, event) {
|
|||||||
this.dragY(event.pageY);
|
this.dragY(event.pageY);
|
||||||
};
|
};
|
||||||
|
|
||||||
GridView.prototype.dragCols = function(elem, event) {
|
GridView.prototype.dragCols = function(event) {
|
||||||
let dropIndex = Math.min(this.getMousePosCol(event.pageX),
|
let dropIndex = Math.min(this.getMousePosCol(event.pageX),
|
||||||
this.viewSection.viewFields().peekLength - 1);
|
this.viewSection.viewFields().peekLength - 1);
|
||||||
if (this.cellSelector.containsCol(dropIndex)) {
|
if (this.cellSelector.containsCol(dropIndex)) {
|
||||||
@ -1539,6 +1550,7 @@ GridView.prototype.dragCols = function(elem, event) {
|
|||||||
GridView.prototype.dropRows = function() {
|
GridView.prototype.dropRows = function() {
|
||||||
var oldIndices = _.range(this.cellSelector.rowLower(), this.cellSelector.rowUpper() + 1);
|
var oldIndices = _.range(this.cellSelector.rowLower(), this.cellSelector.rowUpper() + 1);
|
||||||
this.moveRows(oldIndices, this.cellSelector.row.dropIndex());
|
this.moveRows(oldIndices, this.cellSelector.row.dropIndex());
|
||||||
|
this.cellSelector.currentDragType(selector.NONE);
|
||||||
};
|
};
|
||||||
|
|
||||||
GridView.prototype.dropCols = function() {
|
GridView.prototype.dropCols = function() {
|
||||||
@ -1552,6 +1564,7 @@ GridView.prototype.dropCols = function() {
|
|||||||
this.currentEditingColumnIndex(idx);
|
this.currentEditingColumnIndex(idx);
|
||||||
}
|
}
|
||||||
this._colClickTime = 0;
|
this._colClickTime = 0;
|
||||||
|
this.cellSelector.currentDragType(selector.NONE);
|
||||||
};
|
};
|
||||||
|
|
||||||
// End of Dragging logic
|
// End of Dragging logic
|
||||||
|
@ -1262,7 +1262,7 @@ const cssViewContentPane = styled('div', `
|
|||||||
flex: auto;
|
flex: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
margin: 12px;
|
margin: 12px;
|
||||||
|
@ -116,7 +116,7 @@ const cssOverlay = styled('div', `
|
|||||||
inset: 0px;
|
inset: 0px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 32px 56px 0px 56px;
|
padding: 20px 56px 20px 56px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@media ${mediaSmall} {
|
@media ${mediaSmall} {
|
||||||
& {
|
& {
|
||||||
@ -131,7 +131,6 @@ const cssSectionWrapper = styled('div', `
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border-bottom-left-radius: 0px;
|
border-bottom-left-radius: 0px;
|
||||||
border-bottom-right-radius: 0px;
|
border-bottom-right-radius: 0px;
|
||||||
|
339
app/client/components/SelectionSummary.ts
Normal file
339
app/client/components/SelectionSummary.ts
Normal file
@ -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<Range>(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<Range>(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<SummaryPart[]>(this, []);
|
||||||
|
private _delayedRecalc = this.autoDispose(Delay.create());
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private _cellSelector: CellSelector,
|
||||||
|
private _tableData: TableData,
|
||||||
|
private _sortedRows: SortedRowSet,
|
||||||
|
private _viewFields: ko.Computed<KoArray<ViewFieldRec>>,
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
`);
|
@ -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;
|
|
@ -15,7 +15,7 @@ import {reportError} from 'app/client/models/errors';
|
|||||||
import {filterBar} from 'app/client/ui/FilterBar';
|
import {filterBar} from 'app/client/ui/FilterBar';
|
||||||
import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu';
|
import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu';
|
||||||
import {buildWidgetTitle} from 'app/client/ui/WidgetTitle';
|
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 {icon} from 'app/client/ui2018/icons';
|
||||||
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||||
import {mod} from 'app/common/gutil';
|
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.maybe((use) => use(vs.activeFilterBar) || use(vs.isRaw) && use(vs.activeFilters).length,
|
||||||
() => dom.create(filterBar, vs)),
|
() => dom.create(filterBar, vs)),
|
||||||
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) =>
|
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) => [
|
||||||
dom('div.view_data_pane_container.flexvbox',
|
dom('div.view_data_pane_container.flexvbox',
|
||||||
cssResizing.cls('', isResizing),
|
cssResizing.cls('', isResizing),
|
||||||
dom.maybe(viewInstance.disableEditing, () =>
|
dom.maybe(viewInstance.disableEditing, () =>
|
||||||
@ -319,9 +319,10 @@ export function buildViewSectionDom(options: {
|
|||||||
dom('div.viewsection_truncated', 'Not all data is shown')
|
dom('div.viewsection_truncated', 'Not all data is shown')
|
||||||
),
|
),
|
||||||
dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)),
|
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); }),
|
dom.on('mousedown', () => { viewModel?.activeSectionId(sectionRowId); }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
2
app/client/declarations.d.ts
vendored
2
app/client/declarations.d.ts
vendored
@ -32,6 +32,7 @@ declare module "app/client/components/BaseView" {
|
|||||||
|
|
||||||
import {Cursor, CursorPos} from 'app/client/components/Cursor';
|
import {Cursor, CursorPos} from 'app/client/components/Cursor';
|
||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
|
import {SelectionSummary} from 'app/client/components/SelectionSummary';
|
||||||
import {Disposable} from 'app/client/lib/dispose';
|
import {Disposable} from 'app/client/lib/dispose';
|
||||||
import BaseRowModel from "app/client/models/BaseRowModel";
|
import BaseRowModel from "app/client/models/BaseRowModel";
|
||||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||||
@ -61,6 +62,7 @@ declare module "app/client/components/BaseView" {
|
|||||||
public disableEditing: ko.Computed<boolean>;
|
public disableEditing: ko.Computed<boolean>;
|
||||||
public isTruncated: ko.Observable<boolean>;
|
public isTruncated: ko.Observable<boolean>;
|
||||||
public tableModel: DataTableModel;
|
public tableModel: DataTableModel;
|
||||||
|
public selectionSummary?: SelectionSummary;
|
||||||
|
|
||||||
constructor(gristDoc: GristDoc, viewSectionModel: any, options?: {addNewRow?: boolean, isPreview?: boolean});
|
constructor(gristDoc: GristDoc, viewSectionModel: any, options?: {addNewRow?: boolean, isPreview?: boolean});
|
||||||
public setCursorPos(cursorPos: CursorPos): void;
|
public setCursorPos(cursorPos: CursorPos): void;
|
||||||
|
@ -30,6 +30,12 @@ export function mouseDrag(onStart: MouseDragStart): DomElementMethod {
|
|||||||
return (elem) => { mouseDragElem(elem, onStart); };
|
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) {
|
function _startDragging(startEv: MouseEvent, elem: HTMLElement, onStart: MouseDragStart) {
|
||||||
const dragHandler = onStart(startEv, elem);
|
const dragHandler = onStart(startEv, elem);
|
||||||
if (dragHandler) {
|
if (dragHandler) {
|
||||||
|
@ -169,18 +169,24 @@ export class TableData extends ActionDispatcher implements SkippableRows {
|
|||||||
return this._rowMap.has(rowId);
|
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
|
* 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.
|
* column of that row. The returned function is faster than getValue() calls.
|
||||||
*/
|
*/
|
||||||
public getRowPropFunc(colId: string): UIRowFunc<CellValue|undefined> {
|
public getRowPropFunc(colId: string): UIRowFunc<CellValue|undefined> {
|
||||||
const rowMap = this._rowMap;
|
|
||||||
return (rowId: UIRowId) => {
|
|
||||||
const colData = this._columns.get(colId);
|
const colData = this._columns.get(colId);
|
||||||
if (!colData) { return undefined; }
|
if (!colData) { return () => undefined; }
|
||||||
const values = colData.values;
|
const values = colData.values;
|
||||||
return values[rowMap.get(rowId as number)!];
|
const rowMap = this._rowMap;
|
||||||
};
|
return (rowId: UIRowId) => values[rowMap.get(rowId as number)!];
|
||||||
}
|
}
|
||||||
|
|
||||||
// By default, no rows are skippable, all are kept.
|
// By default, no rows are skippable, all are kept.
|
||||||
|
BIN
test/fixtures/docs/SelectionSummary.grist
vendored
Normal file
BIN
test/fixtures/docs/SelectionSummary.grist
vendored
Normal file
Binary file not shown.
211
test/nbrowser/SelectionSummary.ts
Normal file
211
test/nbrowser/SelectionSummary.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user