gristlabs_grist-core/app/client/ui/GridViewMenus.ts
Jarosław Sadziński bdd4d3c46e (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
2021-06-18 12:22:13 +02:00

360 lines
13 KiB
TypeScript

import {allCommands} from 'app/client/components/commands';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {testId, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menuDivider, menuItem, menuItemCmd} from 'app/client/ui2018/menus';
import {dom, DomElementArg, styled} from 'grainjs';
import isEqual = require('lodash/isEqual');
interface IView {
addNewColumn: () => void;
showColumn: (colId: number, atIndex: number) => void;
}
interface IViewSection {
viewFields: any;
hiddenColumns: any;
}
/**
* Creates a menu to add a new column. Should be used only when there are hidden columns to display,
* otherwise there is no need for this menu.
*/
export function ColumnAddMenu(gridView: IView, viewSection: IViewSection) {
return [
menuItem(() => gridView.addNewColumn(), 'Add Column'),
menuDivider(),
...viewSection.hiddenColumns().map((col: any) => menuItem(
() => {
gridView.showColumn(col.id(), viewSection.viewFields().peekLength);
// .then(() => gridView.scrollPaneRight());
}, `Show column ${col.label()}`))
];
}
interface IRowContextMenu {
disableInsert: boolean;
disableDelete: boolean;
isViewSorted: boolean;
}
export function RowContextMenu({ disableInsert, disableDelete, isViewSorted }: IRowContextMenu) {
const result: Element[] = [];
if (isViewSorted) {
// When the view is sorted, any newly added records get shifts instantly at the top or
// bottom. It could be very confusing for users who might expect the record to stay above or
// below the active row. Thus in this case we show a single `insert row` command.
result.push(
menuItemCmd(allCommands.insertRecordAfter, 'Insert row',
dom.cls('disabled', disableInsert)),
);
} else {
result.push(
menuItemCmd(allCommands.insertRecordBefore, 'Insert row above',
dom.cls('disabled', disableInsert)),
menuItemCmd(allCommands.insertRecordAfter, 'Insert row below',
dom.cls('disabled', disableInsert)),
);
}
result.push(
menuDivider(),
menuItemCmd(allCommands.deleteRecords, 'Delete',
dom.cls('disabled', disableDelete)),
);
result.push(
menuDivider(),
menuItemCmd(allCommands.copyLink, 'Copy anchor link'));
return result;
}
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 {
filterOpenFunc: () => void;
sortSpec: number[];
colId: number;
}
export function calcFieldsCondition(fields: ViewFieldRec[], condition: (f: ViewFieldRec) => boolean): boolean|"mixed" {
return fields.every(condition) ? true : (fields.some(condition) ? "mixed" : false);
}
export function ColumnContextMenu(options: IColumnContextMenu) {
const { disableModify, filterOpenFunc, colId, sortSpec, isReadonly } = options;
const disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly);
const disableForReadonlyView = dom.cls('disabled', isReadonly);
const addToSortLabel = getAddToSortLabel(sortSpec, colId);
return [
menuItemCmd(allCommands.fieldTabOpen, 'Column Options'),
menuItem(filterOpenFunc, 'Filter Data'),
menuDivider({style: 'margin-bottom: 0;'}),
cssRowMenuItem(
customMenuItem(
allCommands.sortAsc.run,
dom('span', 'Sort', {style: 'flex: 1 0 auto; margin-right: 8px;'},
testId('sort-label')),
icon('Sort', dom.style('transform', 'scaley(-1)')),
'A-Z',
dom.style('flex', ''),
cssCustomMenuItem.cls('-selected', isEqual(sortSpec, [colId])),
testId('sort-asc'),
),
customMenuItem(
allCommands.sortDesc.run,
icon('Sort'),
'Z-A',
cssCustomMenuItem.cls('-selected', isEqual(sortSpec, [-colId])),
testId('sort-dsc'),
),
testId('sort'),
),
menuDivider({style: 'margin-bottom: 0; margin-top: 0;'}),
addToSortLabel ? [
cssRowMenuItem(
customMenuItem(
allCommands.addSortAsc.run,
cssRowMenuLabel(addToSortLabel, testId('add-to-sort-label')),
icon('Sort', dom.style('transform', 'scaley(-1)')),
'A-Z',
cssCustomMenuItem.cls('-selected', sortSpec.includes(colId)),
testId('add-to-sort-asc'),
),
customMenuItem(
allCommands.addSortDesc.run,
icon('Sort'),
'Z-A',
cssCustomMenuItem.cls('-selected', sortSpec.includes(-colId)),
testId('add-to-sort-dsc'),
),
testId('add-to-sort'),
),
menuDivider({style: 'margin-top: 0;'}),
] : null,
menuItemCmd(allCommands.renameField, 'Rename column', disableForReadonlyColumn),
menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView),
freezeMenuItemCmd(options),
menuDivider(),
MultiColumnMenu((options.disableFrozenMenu = true, options)),
testId('column-menu'),
];
}
/**
* Note about available options. There is a difference between clearing values (writing empty
* string, which makes cells blank, including Numeric cells) and converting a column to an empty
* column (i.e. column with empty formula; in this case a Numeric column becomes all 0s today).
*
* We offer both options if data columns are selected. If only formulas, only the second option
* makes sense.
*/
export function MultiColumnMenu(options: IMultiColumnContextMenu) {
const disableForReadonlyColumn = dom.cls('disabled', Boolean(options.disableModify) || options.isReadonly);
const disableForReadonlyView = dom.cls('disabled', options.isReadonly);
const num: number = options.numColumns;
const nameClearColumns = options.isFiltered ?
(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',
disableForReadonlyColumn) : null),
// With data columns selected, offer an additional option to clear out selected cells.
(options.isFormula !== true ?
menuItemCmd(allCommands.clearValues, 'Clear values', disableForReadonlyColumn) : null),
menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),
menuItemCmd(allCommands.deleteFields, nameDeleteColumns, disableForReadonlyColumn),
menuDivider(),
menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left', 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.
function getAddToSortLabel(sortSpec: number[], colId: number): string|undefined {
const columnsInSpec = sortSpec.map((n) => Math.abs(n));
if (sortSpec.length !== 0 && !isEqual(columnsInSpec, [colId])) {
const index = columnsInSpec.indexOf(colId);
if (index > -1) {
return `Sorted (#${index + 1})`;
} else {
return 'Add to sort';
}
}
}
const cssRowMenuItem = styled((...args: DomElementArg[]) => dom('li', {tabindex: '-1'}, ...args), `
display: flex;
outline: none;
`);
const cssRowMenuLabel = styled('div', `
margin-right: 8px;
flex: 1 0 auto;
`);
const cssCustomMenuItem = styled('div', `
padding: 8px 8px;
display: flex;
&:not(:hover) {
background-color: white;
color: black;
--icon-color: black;
}
&:last-of-type {
padding-right: 24px;
flex: 0 0 auto;
}
&:first-of-type {
padding-left: 24px;
flex: 1 0 auto;
}
&-selected, &-selected:not(:hover) {
background-color: ${vars.primaryBg};
color: white;
--icon-color: white;
}
`);
function customMenuItem(action: () => void, ...args: DomElementArg[]) {
const element: HTMLElement = cssCustomMenuItem(
...args,
dom.on('click', () => action()),
);
return element;
}