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 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,
|
||||
}
|
||||
};
|
||||
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,
|
||||
}
|
||||
};
|
||||
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.cellSelector.registerMouseHandlers(rowCallbacks, selector.ROW);
|
||||
this.cellSelector.registerMouseHandlers(colCallbacks, selector.COL);
|
||||
this.cellSelector.registerMouseHandlers(cellCallbacks, selector.CELL);
|
||||
};
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// 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
|
||||
|
||||
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
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 {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<BaseView|null>(vs.viewInstance, (viewInstance) =>
|
||||
dom.maybe<BaseView|null>(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); }),
|
||||
);
|
||||
}
|
||||
|
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 {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<boolean>;
|
||||
public isTruncated: ko.Observable<boolean>;
|
||||
public tableModel: DataTableModel;
|
||||
public selectionSummary?: SelectionSummary;
|
||||
|
||||
constructor(gristDoc: GristDoc, viewSectionModel: any, options?: {addNewRow?: boolean, isPreview?: boolean});
|
||||
public setCursorPos(cursorPos: CursorPos): void;
|
||||
|
@ -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) {
|
||||
|
@ -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<CellValue|undefined> {
|
||||
const rowMap = this._rowMap;
|
||||
return (rowId: UIRowId) => {
|
||||
const colData = this._columns.get(colId);
|
||||
if (!colData) { return undefined; }
|
||||
if (!colData) { return () => undefined; }
|
||||
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.
|
||||
|
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