(core) Change how formula columns can be converted to data.

Summary:
- No longer convert data columns to formula by typing a leading "=". Instead,
  show a tooltip with a link to click if the conversion was intended.
- No longer convert a formula column to data by deleting its formula. Leave the
  column empty instead.
- Offer the option "Convert formula to data" in column menu for formulas.
- Offer the option to "Clear column"
- If a subset of rows is shown, offer "Clear values" and "Clear entire column".

- Add logic to detect when a view shows a subset of all rows.
- Factor out showTooltip() from showTransientTooltip().

- Add a bunch of test cases to cover various combinations (there are small
  variations in options depending on whether all rows are shown, on whether
  multiple columns are selected, and whether columns include data columns).

Test Plan: Added a bunch of test cases.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2746
This commit is contained in:
Dmitry S 2021-03-05 10:17:07 -05:00
parent 8a1e803316
commit 48e90c4998
16 changed files with 377 additions and 156 deletions

View File

@ -642,4 +642,11 @@ BaseView.prototype.createFilterMenu = function(openCtl, field) {
return createFilterMenu(openCtl, this._sectionFilter, field, this._filteredRowSource, this.tableModel.tableData); return createFilterMenu(openCtl, this._sectionFilter, field, this._filteredRowSource, this.tableModel.tableData);
}; };
/**
* Whether the rows shown by this view are a proper subset of all rows in the table.
*/
BaseView.prototype.isFiltered = function() {
return this._filteredRowSource.getNumRows() < this.tableModel.tableData.numRecords();
};
module.exports = BaseView; module.exports = BaseView;

View File

@ -28,6 +28,7 @@ const {onDblClickMatchElem} = require('app/client/lib/dblclick');
// Grist UI Components // Grist UI Components
const {Holder} = require('grainjs'); const {Holder} = require('grainjs');
const {menu} = require('../ui2018/menus'); const {menu} = require('../ui2018/menus');
const {calcFieldsCondition} = require('../ui/GridViewMenus');
const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, RowContextMenu} = require('../ui/GridViewMenus'); const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, RowContextMenu} = 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');
@ -184,6 +185,8 @@ GridView.gridCommands = {
hideField: function() { this.hideField(this.cursor.fieldIndex()); }, hideField: function() { this.hideField(this.cursor.fieldIndex()); },
deleteFields: function() { this.deleteColumns(this.getSelection()); }, deleteFields: function() { this.deleteColumns(this.getSelection()); },
clearValues: function() { this.clearValues(this.getSelection()); }, clearValues: function() { this.clearValues(this.getSelection()); },
clearColumns: function() { this._clearColumns(this.getSelection()); },
convertFormulasToData: function() { this._convertFormulasToData(this.getSelection()); },
copy: function() { return this.copy(this.getSelection()); }, copy: function() { return this.copy(this.getSelection()); },
cut: function() { return this.cut(this.getSelection()); }, cut: function() { return this.cut(this.getSelection()); },
paste: function(pasteObj, cutCallback) { return this.paste(pasteObj, cutCallback); }, paste: function(pasteObj, cutCallback) { return this.paste(pasteObj, cutCallback); },
@ -409,16 +412,18 @@ GridView.prototype.clearSelection = function() {
}; };
/** /**
* Given a selection object, sets all references in the object to the empty string. * Given a selection object, sets all cells referred to by the selection to the empty string. If
* @param {Object} selection * only formula columns are selected, only open the formula editor to the empty formula.
* @param {CopySelection} selection
*/ */
GridView.prototype.clearValues = function(selection) { GridView.prototype.clearValues = function(selection) {
console.debug('GridView.clearValues', selection); console.debug('GridView.clearValues', selection);
selection.rowIds = _.without(selection.rowIds, 'new'); selection.rowIds = _.without(selection.rowIds, 'new');
if (selection.rowIds.length === 0) {
// If only the addRow was selected, don't send an action. // If only the addRow was selected, don't send an action.
return; if (selection.rowIds.length === 0) { return; }
} else if (selection.fields.length === 1 && selection.fields[0].column().isRealFormula()) {
const options = this._getColumnMenuOptions(selection);
if (options.isFormula === true) {
this.activateEditorAtCursor(''); this.activateEditorAtCursor('');
} else { } else {
let clearAction = tableUtil.makeDeleteAction(selection); let clearAction = tableUtil.makeDeleteAction(selection);
@ -428,6 +433,30 @@ GridView.prototype.clearValues = function(selection) {
} }
}; };
GridView.prototype._clearColumns = function(selection) {
const fields = selection.fields;
return this.gristDoc.docModel.columns.sendTableAction(
['BulkUpdateRecord', fields.map(f => f.colRef.peek()), {
isFormula: fields.map(f => true),
formula: fields.map(f => ''),
}]
);
};
GridView.prototype._convertFormulasToData = function(selection) {
// Convert all isFormula columns to data, including empty columns. This is sometimes useful
// (e.g. since a truly empty column undergoes a conversion on first data entry, which may be
// prevented by ACL rules).
const fields = selection.fields.filter(f => f.column.peek().isFormula.peek());
if (!fields.length) { return null; }
return this.gristDoc.docModel.columns.sendTableAction(
['BulkUpdateRecord', fields.map(f => f.colRef.peek()), {
isFormula: fields.map(f => false),
formula: fields.map(f => ''),
}]
);
};
GridView.prototype.selectAll = function() { GridView.prototype.selectAll = function() {
this.cellSelector.selectArea(0, 0, this.getLastDataRowIndex(), this.cellSelector.selectArea(0, 0, this.getLastDataRowIndex(),
this.viewSection.viewFields().peekLength - 1); this.viewSection.viewFields().peekLength - 1);
@ -801,7 +830,7 @@ GridView.prototype.buildDom = function() {
trigger: [], trigger: [],
}); });
}, },
menu(ctl => this.columnContextMenu(ctl, this.getSelection().colIds, field, filterTriggerCtl)), menu(ctl => this.columnContextMenu(ctl, this.getSelection(), field, filterTriggerCtl)),
testId('column-menu-trigger'), testId('column-menu-trigger'),
) )
); );
@ -1223,23 +1252,33 @@ GridView.prototype._selectMovedElements = function(start, end, newIndex, numEles
// =========================================================================== // ===========================================================================
// CONTEXT MENUS // CONTEXT MENUS
GridView.prototype.columnContextMenu = function (ctl, selectedColIds, field, filterTriggerCtl) { GridView.prototype.columnContextMenu = function(ctl, copySelection, field, filterTriggerCtl) {
const selectedColIds = copySelection.colIds;
this.ctxMenuHolder.autoDispose(ctl); this.ctxMenuHolder.autoDispose(ctl);
const isReadonly = this.gristDoc.isReadonly.get(); const options = this._getColumnMenuOptions(copySelection);
if (selectedColIds.length > 1 && selectedColIds.includes(field.column().colId())) { if (selectedColIds.length > 1 && selectedColIds.includes(field.column().colId())) {
return MultiColumnMenu({isReadonly}); return MultiColumnMenu(options);
} else { } else {
return ColumnContextMenu({ return ColumnContextMenu({
disableModify: Boolean(field.disableModify.peek()),
filterOpenFunc: () => filterTriggerCtl.open(), filterOpenFunc: () => filterTriggerCtl.open(),
useNewUI: this.gristDoc.app.useNewUI,
sortSpec: this.gristDoc.viewModel.activeSection.peek().activeSortSpec.peek(), sortSpec: this.gristDoc.viewModel.activeSection.peek().activeSortSpec.peek(),
colId: field.column.peek().id.peek(), colId: field.column.peek().id.peek(),
isReadonly ...options,
}); });
} }
}; };
GridView.prototype._getColumnMenuOptions = function(copySelection) {
return {
numColumns: copySelection.fields.length,
disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()),
isReadonly: this.gristDoc.isReadonly.get(),
isFiltered: this.isFiltered(),
isFormula: calcFieldsCondition(copySelection.fields, f => f.column.peek().isRealFormula.peek()),
};
}
GridView.prototype._columnFilterMenu = function(ctl, field) { GridView.prototype._columnFilterMenu = function(ctl, field) {
this.ctxMenuHolder.autoDispose(ctl); this.ctxMenuHolder.autoDispose(ctl);
return this.createFilterMenu(ctl, field); return this.createFilterMenu(ctl, field);

View File

@ -335,6 +335,14 @@ exports.groups = [{
name: 'deleteFields', name: 'deleteFields',
keys: ['Alt+-'], keys: ['Alt+-'],
desc: 'Delete the currently selected columns' desc: 'Delete the currently selected columns'
}, {
name: 'clearColumns',
keys: [],
desc: 'Clear the selected columns'
}, {
name: 'convertFormulasToData',
keys: [],
desc: 'Convert the selected columns from formula to data'
}, { }, {
name: 'addSection', name: 'addSection',
keys: [], keys: [],

View File

@ -245,6 +245,7 @@ declare module "app/client/models/TableModel" {
constructor(docModel: DocModel, tableData: TableData); constructor(docModel: DocModel, tableData: TableData);
public fetch(force?: boolean): Promise<void>; public fetch(force?: boolean): Promise<void>;
public getAllRows(): ReadonlyArray<number>; public getAllRows(): ReadonlyArray<number>;
public getNumRows(): number;
public getRowGrouping(groupByCol: string): RowGrouping<CellValue>; public getRowGrouping(groupByCol: string): RowGrouping<CellValue>;
public sendTableActions(actions: UserAction[], optDesc?: string): Promise<any[]>; public sendTableActions(actions: UserAction[], optDesc?: string): Promise<any[]>;
public sendTableAction(action: UserAction, optDesc?: string): Promise<any> | undefined; public sendTableAction(action: UserAction, optDesc?: string): Promise<any> | undefined;

View File

@ -133,6 +133,10 @@ export class DataTableModelWithDiff extends DisposableWithEvents implements Data
return this.core.getAllRows(); return this.core.getAllRows();
} }
public getNumRows(): number {
return this.core.getNumRows();
}
public getRowGrouping(groupByCol: string): RowGrouping<CellValue> { public getRowGrouping(groupByCol: string): RowGrouping<CellValue> {
return this.core.getRowGrouping(groupByCol); return this.core.getRowGrouping(groupByCol);
} }

View File

@ -130,6 +130,10 @@ export class DynamicQuerySet extends RowSource {
return this._querySet ? this._querySet.getAllRows() : []; return this._querySet ? this._querySet.getAllRows() : [];
} }
public getNumRows(): number {
return this._querySet ? this._querySet.getNumRows() : 0;
}
/** /**
* Tells whether the query's result got truncated, i.e. not all rows are included. * Tells whether the query's result got truncated, i.e. not all rows are included.
*/ */

View File

@ -35,6 +35,10 @@ TableModel.prototype.getAllRows = function() {
return this.tableData.getRowIds(); return this.tableData.getRowIds();
}; };
TableModel.prototype.getNumRows = function() {
return this.tableData.numRecords();
};
TableModel.prototype.getRowGrouping = function(groupByCol) { TableModel.prototype.getRowGrouping = function(groupByCol) {
var grouping = this.rowGroupings[groupByCol]; var grouping = this.rowGroupings[groupByCol];
if (!grouping) { if (!grouping) {

View File

@ -48,13 +48,16 @@ export type RowsChanged = RowList | typeof ALL;
* and `rowNotify(rows, value)` event to notify listeners of a value associated with a row. * and `rowNotify(rows, value)` event to notify listeners of a value associated with a row.
* For the `rowNotify` event, rows may be the rowset.ALL constant. * For the `rowNotify` event, rows may be the rowset.ALL constant.
*/ */
export class RowSource extends DisposableWithEvents { export abstract class RowSource extends DisposableWithEvents {
/** /**
* Returns an iterable over all rows in this RowSource. Should be implemented by derived classes. * Returns an iterable over all rows in this RowSource. Should be implemented by derived classes.
*/ */
public getAllRows(): RowList { public abstract getAllRows(): RowList;
throw new Error("RowSource#getAllRows: Not implemented");
} /**
* Returns the number of rows in this row source.
*/
public abstract getNumRows(): number;
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -123,6 +126,15 @@ export class RowListener extends DisposableWithEvents {
// MappedRowSource // MappedRowSource
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
/**
* A trivial RowSource returning a fixed list of rows.
*/
export abstract class ArrayRowSource extends RowSource {
constructor(private _rows: RowId[]) { super(); }
public getAllRows(): RowList { return this._rows; }
public getNumRows(): number { return this._rows.length; }
}
/** /**
* MappedRowSource wraps any other RowSource, and passes through all rows, replacing each row * MappedRowSource wraps any other RowSource, and passes through all rows, replacing each row
* identifier with the result of mapperFunc(row) call. * identifier with the result of mapperFunc(row) call.
@ -155,6 +167,10 @@ export class MappedRowSource extends RowSource {
public getAllRows(): RowList { public getAllRows(): RowList {
return Array.from(this.parentRowSource.getAllRows(), this._mapperFunc); return Array.from(this.parentRowSource.getAllRows(), this._mapperFunc);
} }
public getNumRows(): number {
return this.parentRowSource.getNumRows();
}
} }
/** /**
@ -180,6 +196,10 @@ export class ExtendedRowSource extends RowSource {
public getAllRows(): RowList { public getAllRows(): RowList {
return [...this.parentRowSource.getAllRows()].concat(this.extras); return [...this.parentRowSource.getAllRows()].concat(this.extras);
} }
public getNumRows(): number {
return this.parentRowSource.getNumRows() + this.extras.length;
}
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -209,6 +229,10 @@ export class BaseFilteredRowSource extends RowListener implements RowSource {
return this._matchingRows.values(); return this._matchingRows.values();
} }
public getNumRows(): number {
return this._matchingRows.size;
}
public onAddRows(rows: RowList) { public onAddRows(rows: RowList) {
const outputRows = []; const outputRows = [];
for (const r of rows) { for (const r of rows) {
@ -353,6 +377,10 @@ class RowGroupHelper<Value> extends RowSource {
return this.rows.values(); return this.rows.values();
} }
public getNumRows(): number {
return this.rows.size;
}
public _addAll(rows: RowList) { public _addAll(rows: RowList) {
for (const r of rows) { this.rows.add(r); } for (const r of rows) { this.rows.add(r); }
} }

View File

@ -1,4 +1,5 @@
import {allCommands} from 'app/client/components/commands'; import {allCommands} from 'app/client/components/commands';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {testId, vars} from 'app/client/ui2018/cssVars'; import {testId, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {menuDivider, menuItem, menuItemCmd} from 'app/client/ui2018/menus'; import {menuDivider, menuItem, menuItemCmd} from 'app/client/ui2018/menus';
@ -66,19 +67,31 @@ export function RowContextMenu({ disableInsert, disableDelete, isViewSorted }: I
return result; return result;
} }
interface IColumnContextMenu { interface IMultiColumnContextMenu {
disableModify: boolean; // 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;
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';
}
interface IColumnContextMenu extends IMultiColumnContextMenu {
filterOpenFunc: () => void; filterOpenFunc: () => void;
useNewUI: boolean;
sortSpec: number[]; sortSpec: number[];
colId: number; colId: number;
isReadonly: boolean; }
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) { export function ColumnContextMenu(options: IColumnContextMenu) {
const { disableModify, filterOpenFunc, useNewUI, colId, sortSpec, isReadonly } = options; const { disableModify, filterOpenFunc, colId, sortSpec, isReadonly } = options;
if (useNewUI) { const disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly);
const disableForReadonlyView = dom.cls('disabled', isReadonly);
const addToSortLabel = getAddToSortLabel(sortSpec, colId); const addToSortLabel = getAddToSortLabel(sortSpec, colId);
return [ return [
@ -127,58 +140,50 @@ export function ColumnContextMenu(options: IColumnContextMenu) {
), ),
menuDivider({style: 'margin-top: 0;'}), menuDivider({style: 'margin-top: 0;'}),
] : null, ] : null,
menuItemCmd(allCommands.renameField, 'Rename column', menuItemCmd(allCommands.renameField, 'Rename column', disableForReadonlyColumn),
dom.cls('disabled', disableModify || isReadonly)), menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView),
menuItemCmd(allCommands.hideField, 'Hide column',
dom.cls('disabled', isReadonly)), menuDivider(),
menuItemCmd(allCommands.deleteFields, 'Delete column', MultiColumnMenu(options),
dom.cls('disabled', disableModify || isReadonly)),
testId('column-menu'), testId('column-menu'),
// TODO: this piece should be removed after adding the new way to add column
menuDivider(),
menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left',
dom.cls('disabled', isReadonly)),
menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right',
dom.cls('disabled', isReadonly)),
]; ];
} else {
return [
menuItemCmd(allCommands.fieldTabOpen, 'FieldOptions'),
menuDivider(),
menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left'),
menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right'),
menuDivider(),
menuItemCmd(allCommands.renameField, 'Rename column',
dom.cls('disabled', disableModify)),
menuItemCmd(allCommands.hideField, 'Hide column'),
menuItemCmd(allCommands.deleteFields, 'Delete column',
dom.cls('disabled', disableModify)),
menuItem(filterOpenFunc, 'Filter'),
menuDivider(),
menuItemCmd(allCommands.sortAsc, 'Sort ascending'),
menuItemCmd(allCommands.sortDesc, 'Sort descending'),
menuItemCmd(allCommands.addSortAsc, 'Add to sort as ascending'),
menuItemCmd(allCommands.addSortDesc, 'Add to sort as descending'),
];
}
}
interface IMultiColumnContextMenu {
isReadonly: boolean;
} }
/**
* 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) { export function MultiColumnMenu(options: IMultiColumnContextMenu) {
const {isReadonly} = options; 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';
return [ return [
menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left', // TODO This should be made to work too for multiple columns.
dom.cls('disabled', isReadonly)), // menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView),
menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right',
dom.cls('disabled', isReadonly)), // 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(), menuDivider(),
menuItemCmd(allCommands.deleteFields, 'Delete columns', menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left', disableForReadonlyView),
dom.cls('disabled', isReadonly)), menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right', disableForReadonlyView),
]; ];
} }

View File

@ -7,35 +7,68 @@
import {prepareForTransition} from 'app/client/ui/transitions'; import {prepareForTransition} from 'app/client/ui/transitions';
import {testId} from 'app/client/ui2018/cssVars'; import {testId} from 'app/client/ui2018/cssVars';
import {dom, styled} from 'grainjs'; import {dom, DomContents, styled} from 'grainjs';
import Popper from 'popper.js'; import Popper from 'popper.js';
interface ITipOptions { export interface ITipOptions {
// Where to place the tooltip relative to the reference element. Defaults to 'top'. // Where to place the tooltip relative to the reference element. Defaults to 'top'.
// See https://popper.js.org/docs/v1/#popperplacements--codeenumcode // See https://popper.js.org/docs/v1/#popperplacements--codeenumcode
placement?: Popper.Placement; placement?: Popper.Placement;
// When to remove the transient tooltip. Defaults to 2000ms.
timeoutMs?: number;
// When set, a tooltip will replace any previous tooltip with the same key. // When set, a tooltip will replace any previous tooltip with the same key.
key?: string; key?: string;
} }
// Map of open tooltips, mapping the key (from ITipOptions) to the cleanup function that removes export interface ITransientTipOptions extends ITipOptions {
// the tooltip. // When to remove the transient tooltip. Defaults to 2000ms.
const openTooltips = new Map<string, () => void>(); timeoutMs?: number;
}
export function showTransientTooltip(refElem: Element, text: string, options: ITipOptions = {}) { export interface ITooltipControl {
close(): void;
}
// Map of open tooltips, mapping the key (from ITipOptions) to ITooltipControl that allows
// removing the tooltip.
const openTooltips = new Map<string, ITooltipControl>();
/**
* Show tipContent briefly (2s by default), in a tooltip next to refElem (on top of it, by default).
* See also ITipOptions.
*/
export function showTransientTooltip(refElem: Element, tipContent: DomContents, options: ITransientTipOptions = {}) {
const ctl = showTooltip(refElem, () => tipContent, options);
const origClose = ctl.close;
ctl.close = () => { clearTimeout(timer); origClose(); };
const timer = setTimeout(ctl.close, options.timeoutMs || 2000);
}
/**
* Show the return value of tipContent(ctl) in a tooltip next to refElem (on top of it, by default).
* Returns ctl. In both places, ctl is an object with a close() method, which closes the tooltip.
* See also ITipOptions.
*/
export function showTooltip(
refElem: Element, tipContent: (ctl: ITooltipControl) => DomContents, options: ITipOptions = {}
): ITooltipControl {
const placement: Popper.Placement = options.placement || 'top'; const placement: Popper.Placement = options.placement || 'top';
const timeoutMs: number = options.timeoutMs || 2000;
const key = options.key; const key = options.key;
// If we had a previous tooltip with the same key, clean it up. // If we had a previous tooltip with the same key, clean it up.
if (key) { openTooltips.get(key)?.(); } if (key) { openTooltips.get(key)?.close(); }
// Cleanup involves destroying the Popper instance, removing the element, etc.
function close() {
popper.destroy();
dom.domDispose(content);
content.remove();
if (key) { openTooltips.delete(key); }
}
const ctl: ITooltipControl = {close};
// Add the content element. // Add the content element.
const content = cssTooltip({role: 'tooltip'}, text, testId(`transient-tooltip`)); const content = cssTooltip({role: 'tooltip'}, tipContent(ctl), testId(`transient-tooltip`));
document.body.appendChild(content); document.body.appendChild(content);
// Create a popper for positioning the tooltip content relative to refElem. // Create a popper for positioning the tooltip content relative to refElem.
@ -49,16 +82,8 @@ export function showTransientTooltip(refElem: Element, text: string, options: IT
prepareForTransition(content, () => { content.style.opacity = '0'; }); prepareForTransition(content, () => { content.style.opacity = '0'; });
content.style.opacity = ''; content.style.opacity = '';
// Cleanup involves destroying the Popper instance, removing the element, etc. if (key) { openTooltips.set(key, ctl); }
function cleanup() { return ctl;
popper.destroy();
dom.domDispose(content);
content.remove();
if (key) { openTooltips.delete(key); }
clearTimeout(timer);
}
const timer = setTimeout(cleanup, timeoutMs);
if (key) { openTooltips.set(key, cleanup); }
} }
@ -75,6 +100,6 @@ const cssTooltip = styled('div', `
font-size: 10pt; font-size: 10pt;
padding: 8px 16px; padding: 8px 16px;
margin: 4px; margin: 4px;
opacity: 0.65; opacity: 0.75;
transition: opacity 0.2s; transition: opacity 0.2s;
`); `);

View File

@ -19,6 +19,14 @@ BaseEditor.prototype.attach = function(cellElem) {
// No-op by default. // No-op by default.
}; };
/**
* Returns DOM container with the editor, typically present and attached after attach() has been
* called.
*/
BaseEditor.prototype.getDom = function() {
return null;
};
/** /**
* Called to get the value to save back to the cell. * Called to get the value to save back to the cell.
*/ */

View File

@ -0,0 +1,51 @@
import {ITooltipControl, showTooltip} from 'app/client/ui/tooltips';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {dom, styled} from 'grainjs';
export function showTooltipToCreateFormula(editorDom: HTMLElement, convert: () => void) {
function buildTooltip(ctl: ITooltipControl) {
return cssConvertTooltip(icon('Convert'),
cssLink('Convert column to formula',
dom.on('mousedown', (ev) => { ev.preventDefault(); convert(); }),
testId('editor-tooltip-convert'),
),
cssCloseButton(icon('CrossSmall'), dom.on('click', ctl.close)),
);
}
const offerCtl = showTooltip(editorDom, buildTooltip, {key: 'col-to-formula'});
dom.onDisposeElem(editorDom, offerCtl.close);
const lis = dom.onElem(editorDom, 'keydown', () => {
lis.dispose();
offerCtl.close();
});
}
const cssConvertTooltip = styled('div', `
display: flex;
align-items: center;
--icon-color: ${colors.lightGreen};
& > .${cssLink.className} {
margin-left: 8px;
}
`);
const cssCloseButton = styled('div', `
cursor: pointer;
user-select: none;
width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
margin: -4px -4px -4px 8px;
--icon-color: white;
border-radius: 16px;
&:hover {
background-color: white;
--icon-color: black;
}
`);

View File

@ -5,6 +5,7 @@ import {UnsavedChange} from 'app/client/components/UnsavedChanges';
import {DataRowModel} from 'app/client/models/DataRowModel'; import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors'; import {reportError} from 'app/client/models/errors';
import {showTooltipToCreateFormula} from 'app/client/widgets/EditorTooltip';
import {FormulaEditor} from 'app/client/widgets/FormulaEditor'; import {FormulaEditor} from 'app/client/widgets/FormulaEditor';
import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEditor'; import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {CellValue} from "app/common/DocActions"; import {CellValue} from "app/common/DocActions";
@ -72,18 +73,21 @@ export class FieldEditor extends Disposable {
this._cellElem = options.cellElem; this._cellElem = options.cellElem;
const startVal = options.startVal; const startVal = options.startVal;
let offerToMakeFormula = false;
const column = this._field.column(); const column = this._field.column();
let isFormula: boolean, editValue: string|undefined; let isFormula: boolean = column.isRealFormula.peek();
let editValue: string|undefined = startVal;
if (startVal && gutil.startsWith(startVal, '=')) { if (startVal && gutil.startsWith(startVal, '=')) {
// If we entered the cell by typing '=', we immediately convert to formula. if (isFormula || this._field.column().isEmpty()) {
// If we typed '=' on an empty column, convert it to a formula. If on a formula column,
// start editing ignoring the initial '='.
isFormula = true; isFormula = true;
editValue = gutil.removePrefix(startVal, '=') as string; editValue = gutil.removePrefix(startVal, '=') as string;
} else { } else {
// Initially, we mark the field as editing formula if it's a non-empty formula field. This can // If we typed '=' on a non-empty column, only suggest to convert it to a formula.
// be changed by typing "=", but the field won't be an actual formula field until saved. offerToMakeFormula = true;
isFormula = column.isRealFormula.peek(); }
editValue = startVal;
} }
// These are the commands for while the editor is active. // These are the commands for while the editor is active.
@ -108,6 +112,10 @@ export class FieldEditor extends Disposable {
this.rebuildEditor(isFormula, editValue, Number.POSITIVE_INFINITY); this.rebuildEditor(isFormula, editValue, Number.POSITIVE_INFINITY);
if (offerToMakeFormula) {
this._offerToMakeFormula();
}
// Whenever focus returns to the Clipboard component, close the editor by saving the value. // Whenever focus returns to the Clipboard component, close the editor by saving the value.
this._gristDoc.app.on('clipboard_focus', this._saveEdit, this); this._gristDoc.app.on('clipboard_focus', this._saveEdit, this);
@ -161,10 +169,16 @@ export class FieldEditor extends Disposable {
private _makeFormula() { private _makeFormula() {
const editor = this._editorHolder.get(); const editor = this._editorHolder.get();
// On keyPress of "=" on textInput, turn the value into a formula. // On keyPress of "=" on textInput, consider turning the column into a formula.
if (editor && !this._field.editingFormula.peek() && editor.getCursorPos() === 0) { if (editor && !this._field.editingFormula.peek() && editor.getCursorPos() === 0) {
if (this._field.column().isEmpty()) {
// If we typed '=' an empty column, convert it to a formula.
this.rebuildEditor(true, editor.getTextValue(), 0); this.rebuildEditor(true, editor.getTextValue(), 0);
return false; return false;
} else {
// If we typed '=' on a non-empty column, only suggest to convert it to a formula.
this._offerToMakeFormula();
}
} }
return true; // don't stop propagation. return true; // don't stop propagation.
} }
@ -172,7 +186,7 @@ export class FieldEditor extends Disposable {
private _unmakeFormula() { private _unmakeFormula() {
const editor = this._editorHolder.get(); const editor = this._editorHolder.get();
// Only convert to data if we are undoing a to-formula conversion. To convert formula to // Only convert to data if we are undoing a to-formula conversion. To convert formula to
// data, delete the formula first (which makes the column "empty"). // data, use column menu option, or delete the formula first (which makes the column "empty").
if (editor && this._field.editingFormula.peek() && editor.getCursorPos() === 0 && if (editor && this._field.editingFormula.peek() && editor.getCursorPos() === 0 &&
!this._field.column().isRealFormula()) { !this._field.column().isRealFormula()) {
// Restore a plain '=' character. This gives a way to enter "=" at the start if line. The // Restore a plain '=' character. This gives a way to enter "=" at the start if line. The
@ -183,11 +197,27 @@ export class FieldEditor extends Disposable {
return true; // don't stop propagation. return true; // don't stop propagation.
} }
private _offerToMakeFormula() {
const editorDom = this._editorHolder.get()?.getDom();
if (!editorDom) { return; }
showTooltipToCreateFormula(editorDom, () => this._convertEditorToFormula());
}
private _convertEditorToFormula() {
const editor = this._editorHolder.get();
if (editor) {
const editValue = editor.getTextValue();
const formulaValue = editValue.startsWith('=') ? editValue.slice(1) : editValue;
this.rebuildEditor(true, formulaValue, 0);
}
}
private async _saveEdit() { private async _saveEdit() {
return this._saveEditPromise || (this._saveEditPromise = this._doSaveEdit()); return this._saveEditPromise || (this._saveEditPromise = this._doSaveEdit());
} }
// Returns whether the cursor jumped, i.e. current record got reordered. // Returns true if Enter/Shift+Enter should NOT move the cursor, for instance if the current
// record got reordered (i.e. the cursor jumped), or when editing a formula.
private async _doSaveEdit(): Promise<boolean> { private async _doSaveEdit(): Promise<boolean> {
const editor = this._editorHolder.get(); const editor = this._editorHolder.get();
if (!editor) { return false; } if (!editor) { return false; }
@ -205,19 +235,12 @@ export class FieldEditor extends Disposable {
// editingFormula() is used for toggling column headers, and this is deferred to start of // editingFormula() is used for toggling column headers, and this is deferred to start of
// typing (a double-click or Enter) does not immediately set it. (This can cause a // typing (a double-click or Enter) does not immediately set it. (This can cause a
// console.warn below, although harmless.) // console.warn below, although harmless.)
let isFormula = this._field.editingFormula(); const isFormula = this._field.editingFormula();
const col = this._field.column(); const col = this._field.column();
let waitPromise: Promise<unknown>|null = null; let waitPromise: Promise<unknown>|null = null;
if (isFormula) { if (isFormula) {
const formula = editor.getCellValue(); const formula = editor.getCellValue();
if (col.isRealFormula() && formula === "") {
// A somewhat surprising feature: deleting the formula converts the column to data, keeping
// the values. To clear the column, enter an empty formula again (now into a data column).
// TODO: this should probably be made more intuitive.
isFormula = false;
}
// Bundle multiple changes so that we can undo them in one step. // Bundle multiple changes so that we can undo them in one step.
if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) { if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
waitPromise = this._gristDoc.docData.bundleActions(null, () => Promise.all([ waitPromise = this._gristDoc.docData.bundleActions(null, () => Promise.all([
@ -243,6 +266,6 @@ export class FieldEditor extends Disposable {
// Deactivate the editor. We are careful to avoid using `this` afterwards. // Deactivate the editor. We are careful to avoid using `this` afterwards.
this.dispose(); this.dispose();
await waitPromise; await waitPromise;
return (saveIndex !== cursor.rowIndex()); return isFormula || (saveIndex !== cursor.rowIndex());
} }
} }

View File

@ -59,6 +59,10 @@ export class NTextEditor extends NewBaseEditor {
this.textInput.setSelectionRange(pos, pos); this.textInput.setSelectionRange(pos, pos);
} }
public getDom(): HTMLElement {
return this._dom;
}
public getCellValue(): CellValue { public getCellValue(): CellValue {
return this.textInput.value; return this.textInput.value;
} }

View File

@ -65,6 +65,12 @@ export abstract class NewBaseEditor extends Disposable {
*/ */
public abstract attach(cellElem: Element): void; public abstract attach(cellElem: Element): void;
/**
* Returns DOM container with the editor, typically present and attached after attach() has been
* called.
*/
public getDom(): HTMLElement|null { return null; }
/** /**
* Called to get the value to save back to the cell. * Called to get the value to save back to the cell.
*/ */

View File

@ -69,6 +69,10 @@ TextEditor.prototype.attach = function(cellElem) {
this.textInput.setSelectionRange(pos, pos); this.textInput.setSelectionRange(pos, pos);
}; };
TextEditor.prototype.getDom = function() {
return this.dom;
};
TextEditor.prototype.setSizerLimits = function() { TextEditor.prototype.setSizerLimits = function() {
// Set the max width of the sizer to the max we could possibly grow to, so that it knows to wrap // Set the max width of the sizer to the max we could possibly grow to, so that it knows to wrap
// once we reach it. // once we reach it.