(core) Freezing columns on a GridView

Summary:
User can freeze any number of columns, which will not move when a user scrolls grid horizontally.
Main use cases:
- Frozen columns don't move when a user scrolls horizontally
- The number of frozen columns is automatically persisted
- Readonly viewers see frozen columns and can modify them - but the change is not persisted
- On a small screen - frozen columns still moves to the left when scrolled, to reveal at least one column
- There is a single menu option - Toggle freeze - which offers the best action considering selected columns
- When a user clicks a single column - action to freeze/unfreeze is always there
- When a user clicks multiple columns - action is offered only where it makes sens (columns are near the frozen border)

Test Plan: Browser tests

Reviewers: dsagal, paulfitz

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2852
This commit is contained in:
Jarosław Sadziński 2021-06-18 11:22:27 +02:00
parent 698c9d4e40
commit bdd4d3c46e
8 changed files with 366 additions and 16 deletions

View File

@ -149,10 +149,21 @@
cursor: pointer; cursor: pointer;
} }
/* Left most shadow - displayed next to row numbers or when columns are frozen - after last frozen column */
.scroll_shadow_left { .scroll_shadow_left {
height: 100%; /* Just needs to be tall enough to flow off the bottom*/ height: 100%;
width: 0px; width: 0px;
left: 4rem; /* Unfortunately we need to calculate this using scroll position.
We could use sticky position here, but we would need to move this component inside the
scroll pane. We don't want to do this, because we want the scroll shadow to be render
on top of the scroll bar. Fortunately it doesn't jitter on firefox - where scroll event is asynchronous.
Variables used here:
- frozen-width : total width of frozen columns plus row numbers width
- scroll-offset: current left offset of the scroll pane
- frozen-offset: when frozen columns are wider then the screen, we want them to move left initially,
this value is the position where this movement should stop.
*/
left: calc(4em + (var(--frozen-width, 0) - min(var(--frozen-scroll-offset, 0), var(--frozen-offset, 0))) * 1px);
box-shadow: -6px 0 6px 6px #444; box-shadow: -6px 0 6px 6px #444;
/* shadow should only show to the right of it (10px should be enough) */ /* shadow should only show to the right of it (10px should be enough) */
-webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%); -webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);
@ -160,6 +171,33 @@
z-index: 3; z-index: 3;
} }
/* Right shadow - normally not displayed - activated when grid has frozen columns */
.scroll_shadow_frozen {
height: 100%;
width: 0px;
left: 4em;
box-shadow: -8px 0 14px 4px #444;
-webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);
clip-path: polygon(0 0, 28px 0, 24px 100%, 0 100%);
z-index: 3;
position: absolute;
}
/* line that indicates where the frozen columns end */
.frozen_line {
position:absolute;
height: 100%;
width: 2px;
/* this value is the same as for the left shadow - but doesn't need to really on the scroll offset
as this component will be hidden when the scroll starts
*/
left: calc(4em + var(--frozen-width, 0) * 1px);
background-color: #999999;
z-index: 3;
user-select: none;
pointer-events: none
}
.scroll_shadow_top { .scroll_shadow_top {
left: 0; left: 0;
height: 0; height: 0;
@ -181,6 +219,17 @@
border-right: 1px solid lightgray; border-right: 1px solid lightgray;
} }
.gridview_left_border {
position: absolute;
width: 0px; /* Matches rowid width (+border) */
height: 100%;
z-index: 3;
left: calc(4rem);
border-right: 1px solid var(--grist-color-dark-grey) !important;
user-select: none;
pointer-events: none
}
.gridview_header_backdrop_top { .gridview_header_backdrop_top {
width: 100%; width: 100%;
height: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */ height: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */
@ -243,6 +292,49 @@
pointer-events: none; /* prevents row drag shadow from stealing row headers clicks */ pointer-events: none; /* prevents row drag shadow from stealing row headers clicks */
} }
/* ================ Freezing columns */
/* style header and a data field */
.record .field.frozen {
position: sticky;
left: calc(4em + 1px + (var(--frozen-position, 0) - var(--frozen-offset, 0)) * 1px); /* 4em for row number + total width of cells + 1px for border*/
z-index: 1;
}
/* for data field we need to reuse color from record (add-row and zebra stripes) */
.gridview_row .record .field.frozen {
background-color: inherit;
}
/* HACK: add box shadow to fix outline overflow from active cursor */
.gridview_row .record .field.frozen {
box-shadow: 0px 1px 0px white;
}
.gridview_row .record.record-hlines .field.frozen {
box-shadow: 0px 1px 0px var(--grist-color-dark-grey);
}
/* selected field has a transparent color - with frozen fields we can't do it */
.gridview_row .field.frozen.selected {
background-color: var(--grist-color-selection-opaque);
}
/* make room for a frozen line by adding margin to first not frozen field - in header and in data */
.field.frozen + .field:not(.frozen) {
margin-left: 1px;
}
/* printing frozen fields is straightforward - just need to remove transparency */
@media print {
.field.frozen {
background: white !important;
}
.column_names .column_name.frozen {
background: var(--grist-color-light-grey) !important;
}
}
/* Etc */ /* Etc */
.g-column-main-menu { .g-column-main-menu {

View File

@ -29,7 +29,7 @@ const {onDblClickMatchElem} = require('app/client/lib/dblclick');
const {Holder} = require('grainjs'); const {Holder} = require('grainjs');
const {menu} = require('../ui2018/menus'); const {menu} = require('../ui2018/menus');
const {calcFieldsCondition} = require('../ui/GridViewMenus'); const {calcFieldsCondition} = require('../ui/GridViewMenus');
const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, RowContextMenu} = require('../ui/GridViewMenus'); const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, RowContextMenu, freezeAction} = require('../ui/GridViewMenus');
const {setPopupToCreateDom} = require('popweasel'); const {setPopupToCreateDom} = require('popweasel');
const {testId} = require('app/client/ui2018/cssVars'); const {testId} = require('app/client/ui2018/cssVars');
@ -41,6 +41,10 @@ const {testId} = require('app/client/ui2018/cssVars');
// it was. // it was.
const SHORT_CLICK_IN_MS = 500; const SHORT_CLICK_IN_MS = 500;
// size of the plus width ()
const PLUS_WIDTH = 40;
// size of the row number field (we assume 4rem)
const ROW_NUMBER_WIDTH = 52;
/** /**
* GridView component implements the view of a grid of cells. * GridView component implements the view of a grid of cells.
@ -59,7 +63,10 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
this.rowShadowAdjust = 0; // pixel dist from mouse click y-coord and the clicked row's top offset this.rowShadowAdjust = 0; // pixel dist from mouse click y-coord and the clicked row's top offset
this.colShadowAdjust = 0; // ^ for x-coord and clicked col's left offset this.colShadowAdjust = 0; // ^ for x-coord and clicked col's left offset
this.scrollLeft = ko.observable(0); this.scrollLeft = ko.observable(0);
this.isScrolledLeft = this.autoDispose(ko.computed(() => this.scrollLeft() > 0));
this.scrollTop = ko.observable(0); this.scrollTop = ko.observable(0);
this.isScrolledTop = this.autoDispose(ko.computed(() => this.scrollTop() > 0));
this.cellSelector = this.autoDispose(selector.CellSelector.create(this, { 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 // This is a bit of a hack to prevent dragging when there's an open column menu
isDisabled: () => Boolean(!this.ctxMenuHolder.isEmpty()) isDisabled: () => Boolean(!this.ctxMenuHolder.isEmpty())
@ -85,7 +92,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
const leftEdge = this.scrollPane.scrollLeft; const leftEdge = this.scrollPane.scrollLeft;
const rightEdge = leftEdge + viewWidth; const rightEdge = leftEdge + viewWidth;
//If cell doesnt fit onscreen, scroll to fit //If cell doesn't fit onscreen, scroll to fit
const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth); const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth);
this.scrollPane.scrollLeft = this.scrollPane.scrollLeft + scrollShift; this.scrollPane.scrollLeft = this.scrollPane.scrollLeft + scrollShift;
})); }));
@ -94,14 +101,69 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
// Some observables for the scroll markers that show that the view is cut off on a side. // Some observables for the scroll markers that show that the view is cut off on a side.
this.scrollShadow = { this.scrollShadow = {
left: ko.observable(false), left: this.isScrolledLeft,
top: ko.observable(false), top: this.isScrolledTop
}; };
//-------------------------------------------------- //--------------------------------------------------
// Set up row and column context menus. // Set up row and column context menus.
this.ctxMenuHolder = Holder.create(this); this.ctxMenuHolder = Holder.create(this);
//--------------------------------------------------
// Set frozen columns variables
// keep track of the width for this component
this.width = ko.observable(0);
// helper for clarity
this.numFrozen = this.viewSection.numFrozen;
// calculate total width of all frozen columns
this.frozenWidth = this.autoDispose(ko.pureComputed(() => this.colRightOffsets().getSumTo(this.numFrozen())));
// show frozenLine when have some frozen columns and not scrolled left
this.frozenLine = this.autoDispose(ko.pureComputed(() => this.numFrozen() && !this.isScrolledLeft()));
// even if some columns are frozen, we still want to move them left
// when screen is too narrow - here we will calculate how much space
// is needed to move all the frozen columns left in order to show some
// unfrozen columns to user (by default we will try to show at least one not
// frozen column and a plus button)
this.frozenOffset = this.autoDispose(ko.computed(() => {
// get the last field
const fields = this.viewSection.viewFields().all();
const lastField = fields[fields.length-1];
// get the last field width (or zero - grid can have zero columns)
const revealWidth = lastField ? lastField.widthDef() : 0;
// calculate the offset: start from zero, then move all left to hide frozen columns,
// then to right to fill whole width, then to left to reveal last column and plus button
const initialOffset = -this.frozenWidth() - ROW_NUMBER_WIDTH + this.width() - revealWidth - PLUS_WIDTH;
// Final check - we actually don't want to have
// the split (between frozen and normal columns) be moved left too far,
// it should stop at the middle of the available grid space (whole width - row number width).
// This can happen when last column is too wide, and we are not able to show it in a full width.
// To calculate the middle point: hide all frozen columns (by moving them maximum to the left)
// and then move them to right by half width of the section.
const middleOffset = -this.frozenWidth() - ROW_NUMBER_WIDTH + this.width() / 2;
// final offset is the bigger number of those two (offsets are negative - so take
// the number that is closer to 0)
const offset = Math.floor(Math.max(initialOffset, middleOffset));
// offset must be negative (we are moving columns left), if we ended up moving
// frozen columns to the right, don't move them at all
return offset > 0 ? 0 : Math.abs(offset);
}));
// observable for left scroll - but return left only when columns are frozen
// this will be used to move frozen border alongside with the scrollpane
this.frozenScrollOffset = this.autoDispose(ko.computed(() => this.numFrozen() ? this.scrollLeft() : 0));
// observable that will indicate if shadow is needed on top of frozen columns
this.frozenShadow = this.autoDispose(ko.computed(() => {
return this.numFrozen() && this.frozenOffset() && this.isScrolledLeft();
}));
// calculate column right offsets
this.frozenPositions = this.autoDispose(this.viewSection.viewFields().map(function(field){
return ko.pureComputed(() => this.colRightOffsets().getSumTo(field._index()));
}, this));
// calculate frozen state for all columns
this.frozenMap = this.autoDispose(this.viewSection.viewFields().map(function(field){
return ko.pureComputed(() => field._index() < this.numFrozen());
}, this));
//-------------------------------------------------- //--------------------------------------------------
// Create and attach the DOM for the view. // Create and attach the DOM for the view.
@ -110,6 +172,8 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
}, this)); }, this));
this.header = null; this.header = null;
this._cornerDom = null; this._cornerDom = null;
// dom for adding new column - used by freeze calculation
this._modField = null;
this.scrollPane = null; this.scrollPane = null;
this.viewPane = this.autoDispose(this.buildDom()); this.viewPane = this.autoDispose(this.buildDom());
this.attachSelectorHandlers(); this.attachSelectorHandlers();
@ -202,6 +266,23 @@ GridView.gridCommands = {
}, },
addSortDesc: function() { addSortDesc: function() {
addToSort(this.viewSection.activeSortSpec, -this.currentColumn().getRowId()); addToSort(this.viewSection.activeSortSpec, -this.currentColumn().getRowId());
},
toggleFreeze: function() {
// get column selection
const selection = this.getSelection();
// convert it menu option
const options = this._getColumnMenuOptions(selection);
// generate action that is available for freeze toggle
const action = freezeAction(options);
// if no action, do nothing
if (!action) { return; }
// if grist document is in readonly - simply change the value
// without saving
if (this.gristDoc.isReadonly.get()) {
this.viewSection.rawNumFrozen(action.numFrozen);
return;
}
this.viewSection.rawNumFrozen.setAndSave(action.numFrozen);
} }
}; };
@ -252,7 +333,7 @@ GridView.prototype.paste = function(data, cutCallback) {
// as frozen (and get marked as unsorted if necessary) for any update even if the update comes // as frozen (and get marked as unsorted if necessary) for any update even if the update comes
// from a different peer. // from a different peer.
// convert row-wise data to column-wise so that it better resembles a useraction // convert row-wise data to column-wise so that it better resembles a user action
let pasteData = _.unzip(data); let pasteData = _.unzip(data);
let pasteHeight = pasteData[0].length; let pasteHeight = pasteData[0].length;
let pasteWidth = pasteData.length; let pasteWidth = pasteData.length;
@ -701,10 +782,9 @@ GridView.prototype.domToColModel = function(elem, elemType) {
//TODO : is this necessary? make passive. Also this could be removed soon I think //TODO : is this necessary? make passive. Also this could be removed soon I think
GridView.prototype.onScroll = function() { GridView.prototype.onScroll = function() {
var pane = this.scrollPane; var pane = this.scrollPane;
this.scrollShadow.left(pane.scrollLeft > 0);
this.scrollShadow.top(pane.scrollTop > 0);
this.scrollLeft(pane.scrollLeft); this.scrollLeft(pane.scrollLeft);
this.scrollTop(pane.scrollTop); this.scrollTop(pane.scrollTop);
this.width(pane.clientWidth);
}; };
@ -733,7 +813,10 @@ GridView.prototype.buildDom = function() {
return dom( return dom(
'div.gridview_data_pane.flexvbox', 'div.gridview_data_pane.flexvbox',
this.gristDoc.app.addNewUIClass(), this.gristDoc.app.addNewUIClass(),
// offset for frozen columns - how much move them to the left
kd.style('--frozen-offset', this.frozenOffset),
// total width of frozen columns
kd.style('--frozen-width', this.frozenWidth),
// Corner, bars and shadows // Corner, bars and shadows
// Corner and shadows (so it's fixed to the grid viewport) // Corner and shadows (so it's fixed to the grid viewport)
self._cornerDom = dom( self._cornerDom = dom(
@ -741,9 +824,16 @@ GridView.prototype.buildDom = function() {
dom.on('click', () => this.selectAll()), dom.on('click', () => this.selectAll()),
), ),
dom('div.scroll_shadow_top', kd.show(this.scrollShadow.top)), dom('div.scroll_shadow_top', kd.show(this.scrollShadow.top)),
dom('div.scroll_shadow_left', kd.show(this.scrollShadow.left)), dom('div.scroll_shadow_left',
kd.show(this.scrollShadow.left),
// pass current scroll position
kd.style('--frozen-scroll-offset', this.frozenScrollOffset)),
dom('div.frozen_line', kd.show(this.frozenLine)),
dom('div.gridview_header_backdrop_left'), //these hide behind the actual headers to keep them from flashing dom('div.gridview_header_backdrop_left'), //these hide behind the actual headers to keep them from flashing
dom('div.gridview_header_backdrop_top'), dom('div.gridview_header_backdrop_top'),
dom('div.gridview_left_border'), //these hide behind the actual headers to keep them from flashing
// left shadow that will be visible on top of frozen columns
dom('div.scroll_shadow_frozen', kd.show(this.frozenShadow)),
// Drag indicators // Drag indicators
self.colLine = dom( self.colLine = dom(
@ -794,6 +884,8 @@ GridView.prototype.buildDom = function() {
let filterTriggerCtl; let filterTriggerCtl;
return dom( return dom(
'div.column_name.field', 'div.column_name.field',
kd.style('--frozen-position', () => ko.unwrap(this.frozenPositions.at(field._index()))),
kd.toggleClass("frozen", () => ko.unwrap(this.frozenMap.at(field._index()))),
dom.autoDispose(isEditingLabel), dom.autoDispose(isEditingLabel),
dom.testId("GridView_columnLabel"), dom.testId("GridView_columnLabel"),
kd.style('width', field.widthPx), kd.style('width', field.widthPx),
@ -829,8 +921,9 @@ GridView.prototype.buildDom = function() {
); );
}), }),
this.isPreview ? null : kd.maybe(() => !this.gristDoc.isReadonlyKo(), () => ( this.isPreview ? null : kd.maybe(() => !this.gristDoc.isReadonlyKo(), () => (
dom('div.column_name.mod-add-column.field', this._modField = dom('div.column_name.mod-add-column.field',
'+', '+',
kd.style("width", PLUS_WIDTH + 'px'),
dom.on('click', ev => { dom.on('click', ev => {
// If there are no hidden columns, clicking the plus just adds a new column. // If there are no hidden columns, clicking the plus just adds a new column.
// If there are hidden columns, display a dropdown menu. // If there are hidden columns, display a dropdown menu.
@ -880,6 +973,7 @@ GridView.prototype.buildDom = function() {
// rowid dom // rowid dom
dom('div.gridview_data_row_num', dom('div.gridview_data_row_num',
kd.style("width", ROW_NUMBER_WIDTH + 'px'),
dom('div.gridview_data_row_info', dom('div.gridview_data_row_info',
kd.toggleClass('linked_dst', () => { kd.toggleClass('linked_dst', () => {
// Must ensure that linkedRowId is not null to avoid drawing on rows whose // Must ensure that linkedRowId is not null to avoid drawing on rows whose
@ -948,6 +1042,8 @@ GridView.prototype.buildDom = function() {
}); });
return dom( return dom(
'div.field', 'div.field',
kd.style('--frozen-position', () => ko.unwrap(self.frozenPositions.at(field._index()))),
kd.toggleClass("frozen", () => ko.unwrap(self.frozenMap.at(field._index()))),
kd.toggleClass('scissors', isCopyActive), kd.toggleClass('scissors', isCopyActive),
dom.autoDispose(isCopyActive), dom.autoDispose(isCopyActive),
dom.autoDispose(isCellSelected), dom.autoDispose(isCellSelected),
@ -979,6 +1075,7 @@ GridView.prototype.onResize = function() {
} else { } else {
this.scrolly.scheduleUpdateSize(); this.scrolly.scheduleUpdateSize();
} }
this.width(this.scrollPane.clientWidth)
}; };
/** @inheritdoc */ /** @inheritdoc */
@ -1265,7 +1362,10 @@ GridView.prototype.columnContextMenu = function(ctl, copySelection, field, filte
GridView.prototype._getColumnMenuOptions = function(copySelection) { GridView.prototype._getColumnMenuOptions = function(copySelection) {
return { return {
columnIndices: copySelection.fields.map(f => f._index()),
totalColumnCount : this.viewSection.viewFields.peek().peekLength,
numColumns: copySelection.fields.length, numColumns: copySelection.fields.length,
numFrozen: this.viewSection.numFrozen.peek(),
disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()), disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()),
isReadonly: this.gristDoc.isReadonly.get(), isReadonly: this.gristDoc.isReadonly.get(),
isFiltered: this.isFiltered(), isFiltered: this.isFiltered(),

View File

@ -331,6 +331,10 @@ exports.groups = [{
name: 'hideField', name: 'hideField',
keys: ['Alt+Shift+-'], keys: ['Alt+Shift+-'],
desc: 'Hide the currently selected column' desc: 'Hide the currently selected column'
}, {
name: 'toggleFreeze',
keys: [],
desc: 'Freeze or unfreeze selected columns'
}, { }, {
name: 'deleteFields', name: 'deleteFields',
keys: ['Alt+-'], keys: ['Alt+-'],

View File

@ -1,3 +1,6 @@
/*
record class is used for grid view header and rows
*/
.record { .record {
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
@ -10,6 +13,13 @@
border-color: var(--grist-color-dark-grey); border-color: var(--grist-color-dark-grey);
border-left-style: solid; /* left border, against rownumbers div, always on */ border-left-style: solid; /* left border, against rownumbers div, always on */
border-bottom-width: 1px; /* style: none, set by record-hlines*/ border-bottom-width: 1px; /* style: none, set by record-hlines*/
/* Record background is white by default.
It gets overridden by the add row, zebra stripes.
It also gets overridden by selecting rows - but in that case background comes from
selected fields - this still remains white.
TODO: consider making this color the single source
*/
background: white;
} }
.record.record-hlines { /* Overwrites style, width set on element */ .record.record-hlines { /* Overwrites style, width set on element */

View File

@ -89,6 +89,11 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
isSorted: ko.Computed<boolean>; isSorted: ko.Computed<boolean>;
disableDragRows: ko.Computed<boolean>; disableDragRows: ko.Computed<boolean>;
activeFilterBar: modelUtil.CustomComputed<boolean>; activeFilterBar: modelUtil.CustomComputed<boolean>;
// Number of frozen columns
rawNumFrozen: modelUtil.CustomComputed<number>;
// Number for frozen columns to display.
// We won't freeze all the columns on a grid, it will leave at least 1 column unfrozen.
numFrozen: ko.Computed<number>;
// Save all filters of fields in the section. // Save all filters of fields in the section.
saveFilters(): Promise<void>; saveFilters(): Promise<void>;
@ -133,6 +138,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
zebraStripes: false, zebraStripes: false,
customView: '', customView: '',
filterBar: false, filterBar: false,
numFrozen: 0
}; };
this.optionsObj = modelUtil.jsonObservable(this.options, this.optionsObj = modelUtil.jsonObservable(this.options,
(obj: any) => defaults(obj || {}, defaultOptions)); (obj: any) => defaults(obj || {}, defaultOptions));
@ -276,4 +282,17 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this.disableDragRows = ko.pureComputed(() => this.isSorted() || !this.table().supportsManualSort()); this.disableDragRows = ko.pureComputed(() => this.isSorted() || !this.table().supportsManualSort());
this.activeFilterBar = modelUtil.customValue(this.optionsObj.prop('filterBar')); this.activeFilterBar = modelUtil.customValue(this.optionsObj.prop('filterBar'));
// Number of frozen columns
this.rawNumFrozen = modelUtil.customValue(this.optionsObj.prop('numFrozen'));
// Number for frozen columns to display
this.numFrozen = ko.pureComputed(() =>
Math.max(
0,
Math.min(
this.rawNumFrozen(),
this.viewFields().all().length - 1
)
)
);
} }

View File

@ -71,10 +71,14 @@ interface IMultiColumnContextMenu {
// For multiple selection, true/false means the value applies to all columns, 'mixed' means it's // For multiple selection, true/false means the value applies to all columns, 'mixed' means it's
// true for some columns, but not all. // true for some columns, but not all.
numColumns: number; numColumns: number;
numFrozen: number;
disableModify: boolean|'mixed'; // If the columns are read-only. disableModify: boolean|'mixed'; // If the columns are read-only.
isReadonly: boolean; isReadonly: boolean;
isFiltered: boolean; // If this view shows a proper subset of all rows in the table. isFiltered: boolean; // If this view shows a proper subset of all rows in the table.
isFormula: boolean|'mixed'; isFormula: boolean|'mixed';
columnIndices: number[];
totalColumnCount: number;
disableFrozenMenu: boolean;
} }
interface IColumnContextMenu extends IMultiColumnContextMenu { interface IColumnContextMenu extends IMultiColumnContextMenu {
@ -94,6 +98,7 @@ export function ColumnContextMenu(options: IColumnContextMenu) {
const disableForReadonlyView = dom.cls('disabled', isReadonly); const disableForReadonlyView = dom.cls('disabled', isReadonly);
const addToSortLabel = getAddToSortLabel(sortSpec, colId); const addToSortLabel = getAddToSortLabel(sortSpec, colId);
return [ return [
menuItemCmd(allCommands.fieldTabOpen, 'Column Options'), menuItemCmd(allCommands.fieldTabOpen, 'Column Options'),
menuItem(filterOpenFunc, 'Filter Data'), menuItem(filterOpenFunc, 'Filter Data'),
@ -142,9 +147,9 @@ export function ColumnContextMenu(options: IColumnContextMenu) {
] : null, ] : null,
menuItemCmd(allCommands.renameField, 'Rename column', disableForReadonlyColumn), menuItemCmd(allCommands.renameField, 'Rename column', disableForReadonlyColumn),
menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView), menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView),
freezeMenuItemCmd(options),
menuDivider(), menuDivider(),
MultiColumnMenu(options), MultiColumnMenu((options.disableFrozenMenu = true, options)),
testId('column-menu'), testId('column-menu'),
]; ];
} }
@ -165,10 +170,11 @@ export function MultiColumnMenu(options: IMultiColumnContextMenu) {
(num > 1 ? `Clear ${num} entire columns` : 'Clear entire column') : (num > 1 ? `Clear ${num} entire columns` : 'Clear entire column') :
(num > 1 ? `Clear ${num} columns` : 'Clear column'); (num > 1 ? `Clear ${num} columns` : 'Clear column');
const nameDeleteColumns = num > 1 ? `Delete ${num} columns` : 'Delete column'; const nameDeleteColumns = num > 1 ? `Delete ${num} columns` : 'Delete column';
const frozenMenu = options.disableFrozenMenu ? null : freezeMenuItemCmd(options);
return [ return [
// TODO This should be made to work too for multiple columns. // TODO This should be made to work too for multiple columns.
// menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView), // menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView),
frozenMenu ? [frozenMenu, menuDivider()]: null,
// Offered only when selection includes formula columns, and converts only those. // Offered only when selection includes formula columns, and converts only those.
(options.isFormula ? (options.isFormula ?
menuItemCmd(allCommands.convertFormulasToData, 'Convert formula to data', menuItemCmd(allCommands.convertFormulasToData, 'Convert formula to data',
@ -183,10 +189,119 @@ export function MultiColumnMenu(options: IMultiColumnContextMenu) {
menuDivider(), menuDivider(),
menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left', disableForReadonlyView), menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left', disableForReadonlyView),
menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right', disableForReadonlyView), menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right', disableForReadonlyView)
]; ];
} }
export function freezeAction(options: IMultiColumnContextMenu): { text: string; numFrozen: number; } | null {
/**
* When user clicks last column - don't offer freezing
* When user clicks on a normal column - offer him to freeze all the columns to the
* left (inclusive).
* When user clicks on a frozen column - offer him to unfreeze all the columns to the
* right (inclusive)
* When user clicks on a set of columns then:
* - If the set of columns contains the last columns that are frozen - offer unfreezing only those columns
* - If the set of columns is right after the frozen columns or spans across - offer freezing only those columns
*
* All of the above are a single command - toggle freeze
*/
const length = options.numColumns;
// make some assertions - number of columns selected should always be > 0
if (length === 0) { return null; }
const indices = options.columnIndices;
const firstColumnIndex = indices[0];
const lastColumnIndex = indices[indices.length - 1];
const numFrozen = options.numFrozen;
// if set has last column in it - don't offer freezing
if (lastColumnIndex == options.totalColumnCount - 1) {
return null;
}
const isNormalColumn = length === 1 && (firstColumnIndex + 1) > numFrozen;
const isFrozenColumn = length === 1 && (firstColumnIndex+ 1) <= numFrozen;
const isSet = length > 1;
const isLastFrozenSet = isSet && lastColumnIndex + 1 === numFrozen;
const isFirstNormalSet = isSet && firstColumnIndex === numFrozen;
const isSpanSet = isSet && firstColumnIndex <= numFrozen && lastColumnIndex >= numFrozen;
let text = '';
if (!isSet) {
if (isNormalColumn) {
// text to show depends on what user selected and how far are we from
// last frozen column
// if user clicked the first column or a column just after frozen set
if (firstColumnIndex === 0 || firstColumnIndex === numFrozen) {
text = 'Freeze this column';
} else {
// else user clicked any other column that is farther, offer to freeze
// proper number of column
const properNumber = firstColumnIndex - numFrozen + 1;
text = `Freeze ${properNumber} ${numFrozen ? 'more ' : ''}columns`;
}
return {
text,
numFrozen : firstColumnIndex + 1
};
} else if (isFrozenColumn) {
// when user clicked last column in frozen set - offer to unfreeze this column
if (firstColumnIndex + 1 === numFrozen) {
text = `Unfreeze this column`;
} else {
// else user clicked column that is not the last in a frozen set
// offer to unfreeze proper number of columns
const properNumber = numFrozen - firstColumnIndex;
text = `Unfreeze ${properNumber === numFrozen ? 'all' : properNumber} columns`;
}
return {
text,
numFrozen : indices[0]
};
} else {
return null;
}
} else {
if (isLastFrozenSet) {
text = `Unfreeze ${length} columns`;
return {
text,
numFrozen : numFrozen - length
};
} else if (isFirstNormalSet) {
text = `Freeze ${length} columns`;
return {
text,
numFrozen : numFrozen + length
};
} else if (isSpanSet) {
const toFreeze = lastColumnIndex + 1 - numFrozen;
text = `Freeze ${toFreeze == 1 ? 'one more column' : (`${toFreeze} more columns`)}`;
return {
text,
numFrozen : numFrozen + toFreeze
};
} else {
return null;
}
}
}
function freezeMenuItemCmd(options: IMultiColumnContextMenu) {
// calculate action available for this options
const toggle = freezeAction(options);
// if we can't offer freezing - don't create a menu at all
// this shouldn't happen - as current design offers some action on every column
if (!toggle) { return null; }
// create menu item if we have something to offer
return menuItemCmd(allCommands.toggleFreeze, toggle.text);
}
// Returns 'Add to sort' is there are columns in the sort spec but colId is not part of it. Returns // Returns 'Add to sort' is there are columns in the sort spec but colId is not part of it. Returns
// undefined if colId is the only column in the spec. Otherwise returns `Sorted (#N)` where #N is // undefined if colId is the only column in the spec. Otherwise returns `Sorted (#N)` where #N is
// the position (1 based) of colId in the spec. // the position (1 based) of colId in the spec.

View File

@ -43,6 +43,8 @@ export const colors = {
cursor: new CustomProp('color-cursor', '#16B378'), // cursor is lightGreen cursor: new CustomProp('color-cursor', '#16B378'), // cursor is lightGreen
selection: new CustomProp('color-selection', 'rgba(22,179,120,0.15)'), selection: new CustomProp('color-selection', 'rgba(22,179,120,0.15)'),
selectionOpaque: new CustomProp('color-selection-opaque', '#DCF4EB'),
inactiveCursor: new CustomProp('color-inactive-cursor', '#A2E1C9'), inactiveCursor: new CustomProp('color-inactive-cursor', '#A2E1C9'),
hover: new CustomProp('color-hover', '#bfbfbf'), hover: new CustomProp('color-hover', '#bfbfbf'),

View File

@ -1479,6 +1479,14 @@ export async function addColumn(name: string) {
await waitForServer(); await waitForServer();
} }
// Select a range of columns, clicking on col1 and dragging to col2.
export async function selectColumnRange(col1: string, col2: string) {
await getColumnHeader({col: col1}).mouseMove();
await driver.mouseDown();
await getColumnHeader({col: col2}).mouseMove();
await driver.mouseUp();
}
/** /**
* Changes browser window dimension to FullHd for a test suit. * Changes browser window dimension to FullHd for a test suit.
*/ */