mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
698c9d4e40
commit
bdd4d3c46e
@ -149,10 +149,21 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Left most shadow - displayed next to row numbers or when columns are frozen - after last frozen column */
|
||||
.scroll_shadow_left {
|
||||
height: 100%; /* Just needs to be tall enough to flow off the bottom*/
|
||||
height: 100%;
|
||||
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;
|
||||
/* 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%);
|
||||
@ -160,6 +171,33 @@
|
||||
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 {
|
||||
left: 0;
|
||||
height: 0;
|
||||
@ -181,6 +219,17 @@
|
||||
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 {
|
||||
width: 100%;
|
||||
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 */
|
||||
}
|
||||
|
||||
/* ================ 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 */
|
||||
|
||||
.g-column-main-menu {
|
||||
|
@ -29,7 +29,7 @@ const {onDblClickMatchElem} = require('app/client/lib/dblclick');
|
||||
const {Holder} = require('grainjs');
|
||||
const {menu} = require('../ui2018/menus');
|
||||
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 {testId} = require('app/client/ui2018/cssVars');
|
||||
|
||||
@ -41,6 +41,10 @@ const {testId} = require('app/client/ui2018/cssVars');
|
||||
// it was.
|
||||
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.
|
||||
@ -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.colShadowAdjust = 0; // ^ for x-coord and clicked col's left offset
|
||||
this.scrollLeft = ko.observable(0);
|
||||
this.isScrolledLeft = this.autoDispose(ko.computed(() => this.scrollLeft() > 0));
|
||||
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
|
||||
isDisabled: () => Boolean(!this.ctxMenuHolder.isEmpty())
|
||||
@ -85,7 +92,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
||||
const leftEdge = this.scrollPane.scrollLeft;
|
||||
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);
|
||||
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.
|
||||
this.scrollShadow = {
|
||||
left: ko.observable(false),
|
||||
top: ko.observable(false),
|
||||
left: this.isScrolledLeft,
|
||||
top: this.isScrolledTop
|
||||
};
|
||||
|
||||
//--------------------------------------------------
|
||||
// Set up row and column context menus.
|
||||
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.
|
||||
|
||||
@ -110,6 +172,8 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
||||
}, this));
|
||||
this.header = null;
|
||||
this._cornerDom = null;
|
||||
// dom for adding new column - used by freeze calculation
|
||||
this._modField = null;
|
||||
this.scrollPane = null;
|
||||
this.viewPane = this.autoDispose(this.buildDom());
|
||||
this.attachSelectorHandlers();
|
||||
@ -202,6 +266,23 @@ GridView.gridCommands = {
|
||||
},
|
||||
addSortDesc: function() {
|
||||
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
|
||||
// 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 pasteHeight = pasteData[0].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
|
||||
GridView.prototype.onScroll = function() {
|
||||
var pane = this.scrollPane;
|
||||
this.scrollShadow.left(pane.scrollLeft > 0);
|
||||
this.scrollShadow.top(pane.scrollTop > 0);
|
||||
this.scrollLeft(pane.scrollLeft);
|
||||
this.scrollTop(pane.scrollTop);
|
||||
this.width(pane.clientWidth);
|
||||
};
|
||||
|
||||
|
||||
@ -733,7 +813,10 @@ GridView.prototype.buildDom = function() {
|
||||
return dom(
|
||||
'div.gridview_data_pane.flexvbox',
|
||||
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 and shadows (so it's fixed to the grid viewport)
|
||||
self._cornerDom = dom(
|
||||
@ -741,9 +824,16 @@ GridView.prototype.buildDom = function() {
|
||||
dom.on('click', () => this.selectAll()),
|
||||
),
|
||||
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_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
|
||||
self.colLine = dom(
|
||||
@ -794,6 +884,8 @@ GridView.prototype.buildDom = function() {
|
||||
let filterTriggerCtl;
|
||||
return dom(
|
||||
'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.testId("GridView_columnLabel"),
|
||||
kd.style('width', field.widthPx),
|
||||
@ -829,8 +921,9 @@ GridView.prototype.buildDom = function() {
|
||||
);
|
||||
}),
|
||||
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 => {
|
||||
// If there are no hidden columns, clicking the plus just adds a new column.
|
||||
// If there are hidden columns, display a dropdown menu.
|
||||
@ -880,6 +973,7 @@ GridView.prototype.buildDom = function() {
|
||||
|
||||
// rowid dom
|
||||
dom('div.gridview_data_row_num',
|
||||
kd.style("width", ROW_NUMBER_WIDTH + 'px'),
|
||||
dom('div.gridview_data_row_info',
|
||||
kd.toggleClass('linked_dst', () => {
|
||||
// Must ensure that linkedRowId is not null to avoid drawing on rows whose
|
||||
@ -948,6 +1042,8 @@ GridView.prototype.buildDom = function() {
|
||||
});
|
||||
return dom(
|
||||
'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),
|
||||
dom.autoDispose(isCopyActive),
|
||||
dom.autoDispose(isCellSelected),
|
||||
@ -979,6 +1075,7 @@ GridView.prototype.onResize = function() {
|
||||
} else {
|
||||
this.scrolly.scheduleUpdateSize();
|
||||
}
|
||||
this.width(this.scrollPane.clientWidth)
|
||||
};
|
||||
|
||||
/** @inheritdoc */
|
||||
@ -1265,7 +1362,10 @@ GridView.prototype.columnContextMenu = function(ctl, copySelection, field, filte
|
||||
|
||||
GridView.prototype._getColumnMenuOptions = function(copySelection) {
|
||||
return {
|
||||
columnIndices: copySelection.fields.map(f => f._index()),
|
||||
totalColumnCount : this.viewSection.viewFields.peek().peekLength,
|
||||
numColumns: copySelection.fields.length,
|
||||
numFrozen: this.viewSection.numFrozen.peek(),
|
||||
disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()),
|
||||
isReadonly: this.gristDoc.isReadonly.get(),
|
||||
isFiltered: this.isFiltered(),
|
||||
|
@ -331,6 +331,10 @@ exports.groups = [{
|
||||
name: 'hideField',
|
||||
keys: ['Alt+Shift+-'],
|
||||
desc: 'Hide the currently selected column'
|
||||
}, {
|
||||
name: 'toggleFreeze',
|
||||
keys: [],
|
||||
desc: 'Freeze or unfreeze selected columns'
|
||||
}, {
|
||||
name: 'deleteFields',
|
||||
keys: ['Alt+-'],
|
||||
|
@ -1,3 +1,6 @@
|
||||
/*
|
||||
record class is used for grid view header and rows
|
||||
*/
|
||||
.record {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
@ -10,6 +13,13 @@
|
||||
border-color: var(--grist-color-dark-grey);
|
||||
border-left-style: solid; /* left border, against rownumbers div, always on */
|
||||
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 */
|
||||
|
@ -89,6 +89,11 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
|
||||
isSorted: ko.Computed<boolean>;
|
||||
disableDragRows: ko.Computed<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.
|
||||
saveFilters(): Promise<void>;
|
||||
@ -133,6 +138,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
||||
zebraStripes: false,
|
||||
customView: '',
|
||||
filterBar: false,
|
||||
numFrozen: 0
|
||||
};
|
||||
this.optionsObj = modelUtil.jsonObservable(this.options,
|
||||
(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.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
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -71,10 +71,14 @@ interface IMultiColumnContextMenu {
|
||||
// For multiple selection, true/false means the value applies to all columns, 'mixed' means it's
|
||||
// true for some columns, but not all.
|
||||
numColumns: number;
|
||||
numFrozen: number;
|
||||
disableModify: boolean|'mixed'; // If the columns are read-only.
|
||||
isReadonly: boolean;
|
||||
isFiltered: boolean; // If this view shows a proper subset of all rows in the table.
|
||||
isFormula: boolean|'mixed';
|
||||
columnIndices: number[];
|
||||
totalColumnCount: number;
|
||||
disableFrozenMenu: boolean;
|
||||
}
|
||||
|
||||
interface IColumnContextMenu extends IMultiColumnContextMenu {
|
||||
@ -94,6 +98,7 @@ export function ColumnContextMenu(options: IColumnContextMenu) {
|
||||
const disableForReadonlyView = dom.cls('disabled', isReadonly);
|
||||
|
||||
const addToSortLabel = getAddToSortLabel(sortSpec, colId);
|
||||
|
||||
return [
|
||||
menuItemCmd(allCommands.fieldTabOpen, 'Column Options'),
|
||||
menuItem(filterOpenFunc, 'Filter Data'),
|
||||
@ -142,9 +147,9 @@ export function ColumnContextMenu(options: IColumnContextMenu) {
|
||||
] : null,
|
||||
menuItemCmd(allCommands.renameField, 'Rename column', disableForReadonlyColumn),
|
||||
menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView),
|
||||
|
||||
freezeMenuItemCmd(options),
|
||||
menuDivider(),
|
||||
MultiColumnMenu(options),
|
||||
MultiColumnMenu((options.disableFrozenMenu = true, options)),
|
||||
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} columns` : 'Clear column');
|
||||
const nameDeleteColumns = num > 1 ? `Delete ${num} columns` : 'Delete column';
|
||||
const frozenMenu = options.disableFrozenMenu ? null : freezeMenuItemCmd(options);
|
||||
return [
|
||||
// TODO This should be made to work too for multiple columns.
|
||||
// menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView),
|
||||
|
||||
frozenMenu ? [frozenMenu, menuDivider()]: null,
|
||||
// Offered only when selection includes formula columns, and converts only those.
|
||||
(options.isFormula ?
|
||||
menuItemCmd(allCommands.convertFormulasToData, 'Convert formula to data',
|
||||
@ -183,10 +189,119 @@ export function MultiColumnMenu(options: IMultiColumnContextMenu) {
|
||||
|
||||
menuDivider(),
|
||||
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
|
||||
// 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.
|
||||
|
@ -43,6 +43,8 @@ export const colors = {
|
||||
|
||||
cursor: new CustomProp('color-cursor', '#16B378'), // cursor is lightGreen
|
||||
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'),
|
||||
|
||||
hover: new CustomProp('color-hover', '#bfbfbf'),
|
||||
|
@ -1479,6 +1479,14 @@ export async function addColumn(name: string) {
|
||||
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.
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user