diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index b71f190b..afdf126f 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -290,6 +290,18 @@ BaseView.prototype.activateEditorAtCursor = function(input) { } }; +/** + * Move the floating RowModel for editing to the current cursor position, and return it. + * + * This is used for opening the formula editor in the side panel; the current row is used to get + * possible exception info from the formula. + */ +BaseView.prototype.moveEditRowToCursor = function() { + var rowId = this.viewData.getRowId(this.cursor.rowIndex()); + this.editRowModel.assign(rowId); + return this.editRowModel; +}; + // Copy an anchor link for the current row to the clipboard. BaseView.prototype.copyLink = async function() { const rowId = this.viewData.getRowId(this.cursor.rowIndex()); diff --git a/app/client/components/FieldConfigTab.js b/app/client/components/FieldConfigTab.js deleted file mode 100644 index e60bb5b6..00000000 --- a/app/client/components/FieldConfigTab.js +++ /dev/null @@ -1,169 +0,0 @@ -var ko = require('knockout'); -var dispose = require('../lib/dispose'); -var dom = require('../lib/dom'); -var kd = require('../lib/koDom'); -var kf = require('../lib/koForm'); -var modelUtil = require('../models/modelUtil'); -var gutil = require('app/common/gutil'); -var AceEditor = require('./AceEditor'); -var RefSelect = require('./RefSelect'); - -const {dom: grainjsDom, makeTestId} = require('grainjs'); -const testId = makeTestId('test-fconfigtab-'); - -function FieldConfigTab(options) { - this.gristDoc = options.gristDoc; - this.fieldBuilder = options.fieldBuilder; - - this.origColRef = this.autoDispose(ko.computed(() => - this.fieldBuilder() ? this.fieldBuilder().origColumn.origColRef() : null)); - - this.isColumnValid = this.autoDispose(ko.computed(() => Boolean(this.origColRef()))); - - this.origColumn = this.autoDispose( - this.gristDoc.docModel.columns.createFloatingRowModel(this.origColRef)); - - this.disableModify = this.autoDispose(ko.computed(() => - this.origColumn.disableModify() || this.origColumn.isTransforming())); - - this.colId = modelUtil.customComputed({ - read: () => this.origColumn.colId(), - save: val => this.origColumn.colId.saveOnly(val) - }); - - this.showColId = this.autoDispose(ko.pureComputed({ - read: () => { - let label = this.origColumn.label(); - let derivedColId = label ? gutil.sanitizeIdent(label) : null; - return derivedColId === this.colId() && !this.origColumn.untieColIdFromLabel(); - } - })); - - this.isDerivedFromLabel = this.autoDispose(ko.pureComputed({ - read: () => !this.origColumn.untieColIdFromLabel(), - write: newValue => this.origColumn.untieColIdFromLabel.saveOnly(!newValue) - })); - - // Indicates whether this is a ref col that references a different table. - this.isForeignRefCol = this.autoDispose(ko.pureComputed(() => { - let type = this.origColumn.type(); - return type && gutil.startsWith(type, 'Ref:') && - this.origColumn.table().tableId() !== gutil.removePrefix(type, 'Ref:'); - })); - - // Create an instance of AceEditor that can be built for each column - this.formulaEditor = this.autoDispose(AceEditor.create({observable: this.origColumn.formula})); - - // Builder for the reference display column multiselect. - this.refSelect = this.autoDispose(RefSelect.create(this)); - - if (options.contentCallback) { - options.contentCallback(this.buildConfigDomObj()); - } else { - this.autoDispose(this.gristDoc.addOptionsTab( - 'Field', dom('span.glyphicon.glyphicon-sort-by-attributes'), - this.buildConfigDomObj(), - { 'category': 'options', 'show': this.fieldBuilder } - )); - } -} -dispose.makeDisposable(FieldConfigTab); - -// Builds object with FieldConfigTab dom builder and settings for the sidepane. -// TODO: Field still cannot be filtered/filter settings cannot be opened from FieldConfigTab. -// This should be considered. -FieldConfigTab.prototype.buildConfigDomObj = function() { - return [{ - 'buildDom': this._buildNameDom.bind(this), - 'keywords': ['field', 'column', 'name', 'title'] - }, { - 'header': true, - 'items': [{ - 'buildDom': this._buildFormulaDom.bind(this), - 'keywords': ['field', 'column', 'formula'] - }] - }, { - 'header': true, - 'label': 'Format Cells', - 'items': [{ - 'buildDom': this._buildFormatDom.bind(this), - 'keywords': ['field', 'type', 'widget', 'options', 'alignment', 'justify', 'justification'] - }] - }, { - 'header': true, - 'label': 'Additional Columns', - 'showObs': this.isForeignRefCol, - 'items': [{ - 'buildDom': () => this.refSelect.buildDom(), - 'keywords': ['additional', 'columns', 'reference', 'formula'] - }] - }, { - 'header': true, - 'label': 'Transform', - 'items': [{ - 'buildDom': this._buildTransformDom.bind(this), - 'keywords': ['field', 'type'] - }] - }]; -}; - -FieldConfigTab.prototype._buildNameDom = function() { - return grainjsDom.maybe(this.isColumnValid, () => dom('div', - kf.row( - 1, dom('div.glyphicon.glyphicon-sort-by-attributes.config_icon'), - 4, kf.label('Field'), - 13, kf.text(this.origColumn.label, { disabled: this.disableModify }, - dom.testId("FieldConfigTab_fieldLabel"), - testId('field-label')) - ), - kf.row( - kd.hide(this.showColId), - 1, dom('div.glyphicon.glyphicon-tag.config_icon'), - 4, kf.label('ID'), - 13, kf.text(this.colId, { disabled: this.disableModify }, - dom.testId("FieldConfigTab_colId"), - testId('field-col-id')) - ), - kf.row( - 8, kf.lightLabel("Use Name as ID?"), - 1, kf.checkbox(this.isDerivedFromLabel, - dom.testId("FieldConfigTab_deriveId"), - testId('field-derive-id')) - ) - )); -}; - -FieldConfigTab.prototype._buildFormulaDom = function() { - return grainjsDom.maybe(this.isColumnValid, () => dom('div', - kf.row( - 3, kf.buttonGroup( - kf.checkButton(this.origColumn.isFormula, - dom('span.formula_button_f', '\u0192'), - dom('span.formula_button_x', 'x'), - kd.toggleClass('disabled', this.disableModify), - { title: 'Change to formula column' } - ) - ), - 15, dom('div.transform_editor', this.formulaEditor.buildDom()) - ), - kf.helpRow( - 3, dom('span'), - 15, kf.lightLabel(kd.text( - () => this.origColumn.isFormula() ? 'Formula' : 'Default Formula')) - ) - )); -}; - -FieldConfigTab.prototype._buildTransformDom = function() { - return grainjsDom.maybe(this.fieldBuilder, builder => builder.buildTransformDom()); -}; - -FieldConfigTab.prototype._buildFormatDom = function() { - return grainjsDom.maybe(this.fieldBuilder, builder => [ - builder.buildSelectTypeDom(), - builder.buildSelectWidgetDom(), - builder.buildConfigDom() - ]); -}; - -module.exports = FieldConfigTab; diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 6e05e99a..4f349a45 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -435,12 +435,7 @@ 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 => ''), - }] - ); + return this.gristDoc.clearColumns(fields.map(f => f.colRef.peek())); }; GridView.prototype._convertFormulasToData = function(selection) { @@ -449,12 +444,7 @@ GridView.prototype._convertFormulasToData = function(selection) { // 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 => ''), - }] - ); + return this.gristDoc.convertFormulasToData(fields.map(f => f.colRef.peek())); }; GridView.prototype.selectAll = function() { diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index dfc6173a..b8d6d085 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -492,6 +492,26 @@ export class GristDoc extends DisposableWithEvents { } } + // Turn the given columns into empty columns, losing any data stored in them. + public async clearColumns(colRefs: number[]): Promise { + await this.docModel.columns.sendTableAction( + ['BulkUpdateRecord', colRefs, { + isFormula: colRefs.map(f => true), + formula: colRefs.map(f => ''), + }] + ); + } + + // Convert the given columns to data, saving the calculated values and unsetting the formulas. + public async convertFormulasToData(colRefs: number[]): Promise { + return this.docModel.columns.sendTableAction( + ['BulkUpdateRecord', colRefs, { + isFormula: colRefs.map(f => false), + formula: colRefs.map(f => ''), + }] + ); + } + public getCsvLink() { return this.docComm.docUrl('gen_csv') + '?' + encodeQueryParams({ ...this.docComm.getUrlParams(), diff --git a/app/client/components/RefSelect.js b/app/client/components/RefSelect.js index 5865f3a9..ee4a5fb7 100644 --- a/app/client/components/RefSelect.js +++ b/app/client/components/RefSelect.js @@ -14,15 +14,21 @@ const {menu, menuItem, menuText} = require('app/client/ui2018/menus'); /** * Builder for the reference display multiselect. */ -function RefSelect(fieldConfigTab) { - this.docModel = fieldConfigTab.gristDoc.docModel; - this.origColumn = fieldConfigTab.origColumn; - this.colId = fieldConfigTab.colId; - this.isForeignRefCol = fieldConfigTab.isForeignRefCol; +function RefSelect(options) { + this.docModel = options.docModel; + this.origColumn = options.origColumn; + this.colId = this.origColumn.colId; + + // Indicates whether this is a ref col that references a different table. + // (That's the only time when RefSelect is offered.) + this.isForeignRefCol = this.autoDispose(ko.computed(() => { + const t = this.origColumn.refTable(); + return Boolean(t && t.getRowId() !== this.origColumn.parentId()); + })); // Computed for the current fieldBuilder's field, if it exists. this.fieldObs = this.autoDispose(ko.computed(() => { - let builder = fieldConfigTab.fieldBuilder(); + let builder = options.fieldBuilder(); return builder ? builder.field : null; })); diff --git a/app/client/components/ViewPane.ts b/app/client/components/ViewPane.ts index 8496d5aa..3efab565 100644 --- a/app/client/components/ViewPane.ts +++ b/app/client/components/ViewPane.ts @@ -1,6 +1,6 @@ // This module is unused except to group some modules for a webpack bundle. // TODO It is a vestige of the old ViewPane.js, and can go away with some bundling improvements. -import * as FieldConfigTab from 'app/client/components/FieldConfigTab'; import * as ViewConfigTab from 'app/client/components/ViewConfigTab'; -export {FieldConfigTab, ViewConfigTab}; +import * as FieldConfig from 'app/client/ui/FieldConfig'; +export {ViewConfigTab, FieldConfig}; diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index 3d485fec..3492bed9 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -3,7 +3,6 @@ declare module "app/client/components/Clipboard"; declare module "app/client/components/CodeEditorPanel"; declare module "app/client/components/DetailView"; declare module "app/client/components/DocConfigTab"; -declare module "app/client/components/FieldConfigTab"; declare module "app/client/components/GridView"; declare module "app/client/components/Layout"; declare module "app/client/components/LayoutEditor"; @@ -38,10 +37,12 @@ declare module "app/client/components/BaseView" { import {Disposable} from 'app/client/lib/dispose'; import {KoArray} from "app/client/lib/koArray"; import * as BaseRowModel from "app/client/models/BaseRowModel"; + import {DataRowModel} from 'app/client/models/DataRowModel'; import {LazyArrayModel} from "app/client/models/DataTableModel"; import * as DataTableModel from "app/client/models/DataTableModel"; import {ViewFieldRec, ViewSectionRec} from "app/client/models/DocModel"; import {SortedRowSet} from 'app/client/models/rowset'; + import {FieldBuilder} from "app/client/widgets/FieldBuilder"; import {DomArg} from 'grainjs'; import {IOpenController} from 'popweasel'; @@ -53,7 +54,7 @@ declare module "app/client/components/BaseView" { public gristDoc: GristDoc; public cursor: Cursor; public sortedRows: SortedRowSet; - public activeFieldBuilder: ko.Computed; + public activeFieldBuilder: ko.Computed; public disableEditing: ko.Computed; public isTruncated: ko.Observable; protected tableModel: DataTableModel; @@ -65,29 +66,31 @@ declare module "app/client/components/BaseView" { public getLoadingDonePromise(): Promise; public onResize(): void; public prepareToPrint(onOff: boolean): void; + public moveEditRowToCursor(): DataRowModel; } export = BaseView; } -declare module "app/client/components/FieldConfigTab" { +declare module "app/client/components/RefSelect" { import {GristDoc, TabContent} from 'app/client/components/GristDoc'; import {Disposable} from 'app/client/lib/dispose'; + import {ColumnRec} from "app/client/models/DocModel"; import {DomArg} from 'grainjs'; + import {DocModel} from "app/client/models/DocModel"; + import {FieldBuilder} from "app/client/widgets/FieldBuilder"; - namespace FieldConfigTab {} - class FieldConfigTab extends Disposable { + namespace RefSelect {} + class RefSelect extends Disposable { public isForeignRefCol: ko.Computed; - public refSelect: any; - constructor(options: {gristDoc: GristDoc, fieldBuilder: unknown, contentCallback: unknown}); - public buildConfigDomObj(): TabContent[]; - // TODO: these should be made private or renamed. - public _buildNameDom(): DomArg; - public _buildFormulaDom(): DomArg; - public _buildTransformDom(): DomArg; - public _buildFormatDom(): DomArg; + constructor(options: { + docModel: DocModel, + origColumn: ColumnRec, + fieldBuilder: ko.Computed, + }); + public buildDom(): HTMLElement; } - export = FieldConfigTab; + export = RefSelect; } declare module "app/client/components/ViewConfigTab" { diff --git a/app/client/models/entities/ColumnRec.ts b/app/client/models/entities/ColumnRec.ts index 3b5fd054..d324fd0e 100644 --- a/app/client/models/entities/ColumnRec.ts +++ b/app/client/models/entities/ColumnRec.ts @@ -32,8 +32,9 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> { // The column's display column _displayColModel: ko.Computed; - disableModify: ko.Computed; - disableEditData: ko.Computed; + disableModifyBase: ko.Computed; // True if column config can't be modified (name, type, etc.) + disableModify: ko.Computed; // True if column can't be modified or is being transformed. + disableEditData: ko.Computed; // True to disable editing of the data in this column. isHiddenCol: ko.Computed; @@ -78,7 +79,8 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void { } }; - this.disableModify = ko.pureComputed(() => Boolean(this.summarySourceCol())); + this.disableModifyBase = ko.pureComputed(() => Boolean(this.summarySourceCol())); + this.disableModify = ko.pureComputed(() => this.disableModifyBase() || this.isTransforming()); this.disableEditData = ko.pureComputed(() => Boolean(this.summarySourceCol())); this.isHiddenCol = ko.pureComputed(() => gristTypes.isHiddenCol(this.colId())); diff --git a/app/client/ui/CodeHighlight.ts b/app/client/ui/CodeHighlight.ts new file mode 100644 index 00000000..b48c1661 --- /dev/null +++ b/app/client/ui/CodeHighlight.ts @@ -0,0 +1,73 @@ +import {colors, vars} from 'app/client/ui2018/cssVars'; +import * as ace from 'brace'; +import {BindableValue, dom, DomElementArg, styled, subscribeElem} from 'grainjs'; + +// tslint:disable:no-var-requires +require('brace/ext/static_highlight'); +require("brace/mode/python"); +require("brace/theme/chrome"); + +export interface ICodeOptions { + placeholder?: string; + maxLines?: number; +} + +export function buildHighlightedCode( + code: BindableValue, options: ICodeOptions, ...args: DomElementArg[] +): HTMLElement { + const highlighter = ace.acequire('ace/ext/static_highlight'); + const PythonMode = ace.acequire('ace/mode/python').Mode; + const theme = ace.acequire('ace/theme/chrome'); + const mode = new PythonMode(); + + return cssHighlightedCode( + dom('div', + elem => subscribeElem(elem, code, (codeText) => { + if (codeText) { + if (options.maxLines) { + // If requested, trim to maxLines, and add an ellipsis at the end. + // (Long lines are also truncated with an ellpsis via text-overflow style.) + const lines = codeText.split(/\n/); + if (lines.length > options.maxLines) { + codeText = lines.slice(0, options.maxLines).join("\n") + " \u2026"; // Ellipsis + } + } + elem.innerHTML = highlighter.render(codeText, mode, theme, 1, true).html; + } else { + elem.textContent = options.placeholder || ''; + } + }), + ), + ...args, + ); +} + +// Use a monospace font, a subset of what ACE editor seems to use. +export const cssCodeBlock = styled('div', ` + font-family: 'Monaco', 'Menlo', monospace; + font-size: ${vars.smallFontSize}; + background-color: ${colors.light}; + &[disabled], &.disabled { + background-color: ${colors.mediumGreyOpaque}; + } +`); + +const cssHighlightedCode = styled(cssCodeBlock, ` + position: relative; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; + border: 1px solid ${colors.darkGrey}; + border-radius: 3px; + min-height: 28px; + padding: 5px 6px; + color: ${colors.slate}; + + &.disabled, &.disabled .ace-chrome { + background-color: ${colors.mediumGreyOpaque}; + } + & .ace_line { + overflow: hidden; + text-overflow: ellipsis; + } +`); diff --git a/app/client/ui/FieldConfig.ts b/app/client/ui/FieldConfig.ts new file mode 100644 index 00000000..17940ef6 --- /dev/null +++ b/app/client/ui/FieldConfig.ts @@ -0,0 +1,197 @@ +import type {GristDoc} from 'app/client/components/GristDoc'; +import type {ColumnRec} from 'app/client/models/entities/ColumnRec'; +import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight'; +import {cssLabel, cssRow} from 'app/client/ui/RightPanel'; +import {colors, testId, vars} from 'app/client/ui2018/cssVars'; +import {textInput} from 'app/client/ui2018/editableLabel'; +import {cssIconButton, icon} from 'app/client/ui2018/icons'; +import {menu, menuItem} from 'app/client/ui2018/menus'; +import {sanitizeIdent} from 'app/common/gutil'; +import {Computed, dom, fromKo, IDisposableOwner, Observable, styled} from 'grainjs'; + +export function buildNameConfig(owner: IDisposableOwner, origColumn: ColumnRec) { + const untieColId = origColumn.untieColIdFromLabel; + + const editedLabel = Observable.create(owner, ''); + const editableColId = Computed.create(owner, editedLabel, (use, edited) => + '$' + (edited ? sanitizeIdent(edited) : use(origColumn.colId))); + const saveColId = (val: string) => origColumn.colId.saveOnly(val.startsWith('$') ? val.slice(1) : val); + + return [ + cssLabel('COLUMN LABEL AND ID'), + cssRow( + cssColLabelBlock( + textInput(fromKo(origColumn.label), + async val => { await origColumn.label.saveOnly(val); editedLabel.set(''); }, + dom.on('input', (ev, elem) => { if (!untieColId.peek()) { editedLabel.set(elem.value); } }), + dom.boolAttr('disabled', origColumn.disableModify), + testId('field-label'), + ), + textInput(editableColId, + saveColId, + dom.boolAttr('disabled', use => use(origColumn.disableModify) || !use(origColumn.untieColIdFromLabel)), + cssCodeBlock.cls(''), + {style: 'margin-top: 8px'}, + testId('field-col-id'), + ), + ), + cssColTieBlock( + cssColTieConnectors(), + cssToggleButton(icon('FieldReference'), + cssToggleButton.cls('-selected', (use) => !use(untieColId)), + dom.on('click', () => untieColId.saveOnly(!untieColId.peek())), + testId('field-derive-id') + ), + ) + ), + ]; +} + +type BuildEditor = (cellElem: Element) => void; + +export function buildFormulaConfig( + owner: IDisposableOwner, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor +) { + const clearColumn = () => gristDoc.clearColumns([origColumn.id.peek()]); + const convertToData = () => gristDoc.convertFormulasToData([origColumn.id.peek()]); + + return dom.maybe(use => { + if (!use(origColumn.id)) { return null; } // Invalid column, show nothing. + if (use(origColumn.isEmpty)) { return "empty"; } + return use(origColumn.isFormula) ? "formula" : "data"; + }, + (type: "empty"|"formula"|"data") => { + function buildHeader(label: string, menuFunc: () => Element[]) { + return cssRow( + cssInlineLabel(label, + testId('field-is-formula-label'), + ), + cssDropdownLabel('Actions', icon('Dropdown'), menu(menuFunc), + cssDropdownLabel.cls('-disabled', origColumn.disableModify), + testId('field-actions-menu'), + ) + ); + } + function buildFormulaRow(placeholder = 'Enter formula') { + return cssRow(dom.create(buildFormula, origColumn, buildEditor, placeholder)); + } + if (type === "empty") { + return [ + buildHeader('EMPTY COLUMN', () => [ + menuItem(clearColumn, 'Clear column', dom.cls('disabled', true)), + menuItem(convertToData, 'Make into data column'), + ]), + buildFormulaRow(), + ]; + } else if (type === "formula") { + return [ + buildHeader('FORMULA COLUMN', () => [ + menuItem(clearColumn, 'Clear column'), + menuItem(convertToData, 'Convert to data column'), + ]), + buildFormulaRow(), + ]; + } else { + return [ + buildHeader('DATA COLUMN', () => [ + menuItem(clearColumn, 'Clear and make into formula'), + ]), + buildFormulaRow('Default formula'), + cssHintRow('Default formula for new records'), + ]; + } + } + ); +} + +function buildFormula(owner: IDisposableOwner, column: ColumnRec, buildEditor: BuildEditor, placeholder: string) { + return cssFieldFormula(column.formula, {placeholder, maxLines: 2}, + dom.cls('formula_field_sidepane'), + cssFieldFormula.cls('-disabled', column.disableModify), + cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)), + dom.cls('disabled'), + {tabIndex: '-1'}, + dom.on('focus', (ev, elem) => buildEditor(elem)), + ); +} + +const cssFieldFormula = styled(buildHighlightedCode, ` + flex: auto; + cursor: pointer; + margin-top: 4px; + padding-left: 24px; + --icon-color: ${colors.lightGreen}; + + &-disabled-icon.formula_field_sidepane::before { + --icon-color: ${colors.slate}; + } + &-disabled { + pointer-events: none; + } +`); + +const cssToggleButton = styled(cssIconButton, ` + margin-left: 8px; + background-color: var(--grist-color-medium-grey-opaque); + box-shadow: inset 0 0 0 1px ${colors.darkGrey}; + + &-selected, &-selected:hover { + box-shadow: none; + background-color: ${colors.dark}; + --icon-color: ${colors.light}; + } + &-selected:hover { + --icon-color: ${colors.darkGrey}; + } +`); + +const cssInlineLabel = styled(cssLabel, ` + padding: 4px 8px; + margin: 4px 0 -4px -8px; +`); + +const cssDropdownLabel = styled(cssInlineLabel, ` + margin-left: auto; + display: flex; + align-items: center; + border-radius: ${vars.controlBorderRadius}; + cursor: pointer; + + color: ${colors.lightGreen}; + --icon-color: ${colors.lightGreen}; + + &:hover, &:focus, &.weasel-popup-open { + background-color: ${colors.mediumGrey}; + } + &-disabled { + color: ${colors.slate}; + --icon-color: ${colors.slate}; + pointer-events: none; + } +`); + +const cssHintRow = styled('div', ` + margin: -4px 16px 8px 16px; + color: ${colors.slate}; + text-align: center; +`); + +const cssColLabelBlock = styled('div', ` + display: flex; + flex-direction: column; +`); + +const cssColTieBlock = styled('div', ` + position: relative; +`); + +const cssColTieConnectors = styled('div', ` + position: absolute; + border: 2px solid var(--grist-color-dark-grey); + top: -9px; + bottom: -9px; + right: 11px; + left: 0px; + border-left: none; + z-index: -1; +`); diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 10291e6d..36494b70 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -15,9 +15,10 @@ */ import * as commands from 'app/client/components/commands'; -import * as FieldConfigTab from 'app/client/components/FieldConfigTab'; import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc'; +import * as RefSelect from 'app/client/components/RefSelect'; import * as ViewConfigTab from 'app/client/components/ViewConfigTab'; +import {domAsync} from 'app/client/lib/domAsync'; import * as imports from 'app/client/lib/imports'; import {createSessionObs} from 'app/client/lib/sessionObs'; import {reportError} from 'app/client/models/AppModel'; @@ -33,8 +34,9 @@ import {textInput} from 'app/client/ui2018/editableLabel'; import {IconName} from 'app/client/ui2018/IconList'; import {icon} from 'app/client/ui2018/icons'; import {select} from 'app/client/ui2018/menus'; +import {FieldBuilder} from 'app/client/widgets/FieldBuilder'; import {StringUnion} from 'app/common/StringUnion'; -import {bundleChanges, Computed, Disposable, dom, DomArg, domComputed, DomContents, +import {bundleChanges, Computed, Disposable, dom, domComputed, DomContents, DomElementArg, DomElementMethod, IDomComponent} from 'grainjs'; import {MultiHolder, Observable, styled, subscribe} from 'grainjs'; import * as ko from 'knockout'; @@ -180,40 +182,53 @@ export class RightPanel extends Disposable { } private _buildFieldContent(owner: MultiHolder) { - const obs: Observable = Observable.create(owner, null); - const fieldBuilder = this.autoDispose(ko.computed(() => { + const fieldBuilder = owner.autoDispose(ko.computed(() => { const vsi = this._gristDoc.viewModel.activeSection().viewInstance(); return vsi && vsi.activeFieldBuilder(); })); - const gristDoc = this._gristDoc; - const content = Observable.create(owner, []); - const contentCallback = (tabs: TabContent[]) => content.set(tabs); - imports.loadViewPane() - .then(ViewPane => { - if (owner.isDisposed()) { return; } - const fct = owner.autoDispose(ViewPane.FieldConfigTab.create({gristDoc, fieldBuilder, contentCallback})); - obs.set(fct); - }) - .catch(reportError); - return dom.maybe(obs, (fct) => - buildConfigContainer( - cssLabel('COLUMN TITLE'), - fct._buildNameDom(), - fct._buildFormulaDom(), - cssSeparator(), - cssLabel('COLUMN TYPE'), - fct._buildFormatDom(), - cssSeparator(), - dom.maybe(fct.isForeignRefCol, () => [ - cssLabel('Add referenced columns'), - cssRow(fct.refSelect.buildDom()), - cssSeparator() - ]), - cssLabel('TRANSFORM'), - fct._buildTransformDom(), - this._disableIfReadonly(), - ) - ); + + const docModel = this._gristDoc.docModel; + const origColRef = owner.autoDispose(ko.computed(() => fieldBuilder()?.origColumn.origColRef() || 0)); + const origColumn = owner.autoDispose(docModel.columns.createFloatingRowModel(origColRef)); + const isColumnValid = owner.autoDispose(ko.computed(() => Boolean(origColRef()))); + + // Builder for the reference display column multiselect. + const refSelect = owner.autoDispose(RefSelect.create({docModel, origColumn, fieldBuilder})); + + return domAsync(imports.loadViewPane().then(ViewPane => { + const {buildNameConfig, buildFormulaConfig} = ViewPane.FieldConfig; + return dom.maybe(isColumnValid, () => + buildConfigContainer( + dom.create(buildNameConfig, origColumn), + cssSeparator(), + dom.create(buildFormulaConfig, origColumn, this._gristDoc, this._activateFormulaEditor.bind(this)), + cssSeparator(), + cssLabel('COLUMN TYPE'), + dom.maybe(fieldBuilder, builder => [ + builder.buildSelectTypeDom(), + builder.buildSelectWidgetDom(), + builder.buildConfigDom() + ]), + cssSeparator(), + dom.maybe(refSelect.isForeignRefCol, () => [ + cssLabel('Add referenced columns'), + cssRow(refSelect.buildDom()), + cssSeparator() + ]), + cssLabel('TRANSFORM'), + dom.maybe(fieldBuilder, builder => builder.buildTransformDom()), + this._disableIfReadonly(), + ) + ); + })); + } + + // Helper to activate the side-pane formula editor over the given HTML element. + private _activateFormulaEditor(refElem: Element) { + const vsi = this._gristDoc.viewModel.activeSection().viewInstance(); + if (!vsi) { return; } + const editRowModel = vsi.moveEditRowToCursor(); + vsi.activeFieldBuilder.peek().openSideFormulaEditor(editRowModel, refElem); } private _buildPageWidgetContent(_owner: MultiHolder) { @@ -452,7 +467,7 @@ export class RightPanel extends Disposable { } } -export function buildConfigContainer(...args: DomElementArg[]): DomArg { +export function buildConfigContainer(...args: DomElementArg[]): HTMLElement { return cssConfigContainer( // The `position: relative;` style is needed for the overlay for the readonly mode. Note that // we cannot set it on the cssConfigContainer directly because it conflicts with how overflow @@ -631,7 +646,7 @@ const cssTabContents = styled('div', ` const cssSeparator = styled('div', ` border-bottom: 1px solid ${colors.mediumGrey}; - margin-top: 24px; + margin-top: 16px; `); const cssConfigContainer = styled('div', ` @@ -681,6 +696,6 @@ const cssListItem = styled('li', ` padding: 4px 8px; `); -const cssTextInput = styled(textInput, ` +export const cssTextInput = styled(textInput, ` flex: 1 0 auto; `); diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index f15d8bef..4fd7c341 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -19,7 +19,7 @@ import { buttonSelect } from 'app/client/ui2018/buttonSelect'; import { IOptionFull, menu, select } from 'app/client/ui2018/menus'; import { DiffBox } from 'app/client/widgets/DiffBox'; import { buildErrorDom } from 'app/client/widgets/ErrorDom'; -import { FieldEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor'; +import { FieldEditor, openSideFormulaEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor'; import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget'; import * as UserType from 'app/client/widgets/UserType'; import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl'; @@ -213,7 +213,7 @@ export class FieldBuilder extends Disposable { cssRow( grainjsDom.autoDispose(selectType), select(selectType, this.availableTypes, { - disabled: (use) => use(this.isTransformingFormula) || use(this.origColumn.disableModify) || + disabled: (use) => use(this.isTransformingFormula) || use(this.origColumn.disableModifyBase) || use(this.isCallPending) }), testId('type-select') @@ -301,7 +301,7 @@ export class FieldBuilder extends Disposable { dom('span.glyphicon.glyphicon-flash'), dom.testId("FieldBuilder_editTransform"), kd.toggleClass('disabled', () => this.isTransformingType() || this.origColumn.isFormula() || - this.origColumn.disableModify()) + this.origColumn.disableModifyBase()) ) ) ), @@ -450,7 +450,6 @@ export class FieldBuilder extends Disposable { } } - public buildEditorDom(editRow: DataRowModel, mainRowModel: DataRowModel, options: { init?: string }) { @@ -495,8 +494,20 @@ export class FieldBuilder extends Disposable { this.gristDoc.fieldEditorHolder.autoDispose(fieldEditor); } - public isEditorActive() { return !this._fieldEditorHolder.isEmpty(); } + + /** + * Open the formula editor in the side pane. It will be positioned over refElem. + */ + public openSideFormulaEditor(editRow: DataRowModel, refElem: Element) { + const editorHolder = openSideFormulaEditor({ + gristDoc: this.gristDoc, + field: this.field, + editRow, + refElem, + }); + this.gristDoc.fieldEditorHolder.autoDispose(editorHolder); + } } diff --git a/app/client/widgets/FieldEditor.ts b/app/client/widgets/FieldEditor.ts index 94ae14c9..7ac0dfc9 100644 --- a/app/client/widgets/FieldEditor.ts +++ b/app/client/widgets/FieldEditor.ts @@ -3,15 +3,17 @@ import {Cursor} from 'app/client/components/Cursor'; import {GristDoc} from 'app/client/components/GristDoc'; import {UnsavedChange} from 'app/client/components/UnsavedChanges'; import {DataRowModel} from 'app/client/models/DataRowModel'; +import {ColumnRec} from 'app/client/models/entities/ColumnRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {reportError} from 'app/client/models/errors'; import {showTooltipToCreateFormula} from 'app/client/widgets/EditorTooltip'; import {FormulaEditor} from 'app/client/widgets/FormulaEditor'; import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEditor'; +import {asyncOnce} from "app/common/AsyncCreate"; import {CellValue} from "app/common/DocActions"; import {isRaisedException} from 'app/common/gristTypes'; import * as gutil from 'app/common/gutil'; -import {Disposable, Holder, Observable} from 'grainjs'; +import {Disposable, Holder, IDisposable, MultiHolder, Observable} from 'grainjs'; import isEqual = require('lodash/isEqual'); type IEditorConstructor = typeof NewBaseEditor; @@ -53,7 +55,7 @@ export class FieldEditor extends Disposable { private _editCommands: IEditorCommandGroup; private _editorCtor: IEditorConstructor; private _editorHolder: Holder = Holder.create(this); - private _saveEditPromise: Promise|null = null; + private _saveEdit = asyncOnce(() => this._doSaveEdit()); constructor(options: { gristDoc: GristDoc, @@ -116,18 +118,7 @@ export class FieldEditor extends Disposable { this._offerToMakeFormula(); } - // Whenever focus returns to the Clipboard component, close the editor by saving the value. - this._gristDoc.app.on('clipboard_focus', this._saveEdit, this); - - // TODO: This should ideally include a callback that returns true only when the editor value - // has changed. Currently an open editor is considered unsaved even when unchanged. - UnsavedChange.create(this, async () => { await this._saveEdit(); }); - - this.onDispose(() => { - this._gristDoc.app.off('clipboard_focus', this._saveEdit, this); - // Unset field.editingFormula flag when the editor closes. - this._field.editingFormula(false); - }); + setupEditorCleanup(this, this._gristDoc, this._field, this._saveEdit); } // cursorPos refers to the position of the caret within the editor. @@ -143,23 +134,12 @@ export class FieldEditor extends Disposable { // we defer this mode until the user types something. this._field.editingFormula(isFormula && editValue !== undefined); - let formulaError: Observable|undefined; - if (column.isFormula() && isRaisedException(cellCurrentValue)) { - const fv = formulaError = Observable.create(null, cellCurrentValue); - this._gristDoc.docData.getFormulaError(column.table().tableId(), - this._field.colId(), - this._editRow.getRowId() - ) - .then(value => { fv.set(value); }) - .catch(reportError); - } - // Replace the item in the Holder with a new one, disposing the previous one. const editor = this._editorHolder.autoDispose(editorCtor.create({ gristDoc: this._gristDoc, field: this._field, cellValue, - formulaError, + formulaError: getFormulaError(this._gristDoc, this._editRow, column), editValue, cursorPos, commands: this._editCommands, @@ -212,10 +192,6 @@ export class FieldEditor extends Disposable { } } - private async _saveEdit() { - return this._saveEditPromise || (this._saveEditPromise = this._doSaveEdit()); - } - // 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 { @@ -269,3 +245,94 @@ export class FieldEditor extends Disposable { return isFormula || (saveIndex !== cursor.rowIndex()); } } + +/** + * Open a formula editor in the side pane. Returns a Disposable that owns the editor. + */ +export function openSideFormulaEditor(options: { + gristDoc: GristDoc, + field: ViewFieldRec, + editRow: DataRowModel, // Needed to get exception value, if any. + refElem: Element, // Element in the side pane over which to position the editor. +}): IDisposable { + const {gristDoc, field, editRow, refElem} = options; + const holder = MultiHolder.create(null); + const column = field.column(); + + // AsyncOnce ensures it's called once even if triggered multiple times. + const saveEdit = asyncOnce(async () => { + const formula = editor.getCellValue(); + if (formula !== column.formula.peek()) { + await column.updateColValues({formula}); + } + holder.dispose(); // Deactivate the editor. + }); + + // These are the commands for while the editor is active. + const editCommands = { + fieldEditSave: () => { saveEdit().catch(reportError); }, + fieldEditSaveHere: () => { saveEdit().catch(reportError); }, + fieldEditCancel: () => { holder.dispose(); }, + }; + + // Replace the item in the Holder with a new one, disposing the previous one. + const editor = FormulaEditor.create(holder, { + gristDoc, + field, + cellValue: column.formula(), + formulaError: getFormulaError(gristDoc, editRow, column), + editValue: undefined, + cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor. + commands: editCommands, + cssClass: 'formula_editor_sidepane', + }); + editor.attach(refElem); + + // Enter formula-editing mode (highlight formula icons; click on a column inserts its ID). + field.editingFormula(true); + setupEditorCleanup(holder, gristDoc, field, saveEdit); + return holder; +} + + +/** + * For an active editor, set up its cleanup: + * - saving on click-away (when focus returns to Grist "clipboard" element) + * - unset field.editingFormula mode + * - Arrange for UnsavedChange protection against leaving the page with unsaved changes. + */ +function setupEditorCleanup( + owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, saveEdit: () => Promise +) { + // Whenever focus returns to the Clipboard component, close the editor by saving the value. + gristDoc.app.on('clipboard_focus', saveEdit); + + // TODO: This should ideally include a callback that returns true only when the editor value + // has changed. Currently an open editor is considered unsaved even when unchanged. + UnsavedChange.create(owner, async () => { await saveEdit(); }); + + owner.onDispose(() => { + gristDoc.app.off('clipboard_focus', saveEdit); + // Unset field.editingFormula flag when the editor closes. + field.editingFormula(false); + }); +} + +/** + * If the cell at the given row and column is a formula value containing an exception, return an + * observable with this exception, and fetch more details to add to the observable. + */ +function getFormulaError( + gristDoc: GristDoc, editRow: DataRowModel, column: ColumnRec +): Observable|undefined { + const colId = column.colId.peek(); + let formulaError: Observable|undefined; + const cellCurrentValue = editRow.cells[colId].peek(); + if (column.isFormula() && isRaisedException(cellCurrentValue)) { + const fv = formulaError = Observable.create(null, cellCurrentValue); + gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId()) + .then(value => { fv.set(value); }) + .catch(reportError); + } + return formulaError; +} diff --git a/app/client/widgets/FormulaEditor.ts b/app/client/widgets/FormulaEditor.ts index 04ad883f..cee0b9d2 100644 --- a/app/client/widgets/FormulaEditor.ts +++ b/app/client/widgets/FormulaEditor.ts @@ -13,6 +13,11 @@ import {dom, Observable, styled} from 'grainjs'; // How wide to expand the FormulaEditor when an error is shown in it. const minFormulaErrorWidth = 400; +export interface IFormulaEditorOptions extends Options { + cssClass?: string; +} + + /** * Required parameters: * @param {RowModel} options.field: ViewSectionField (i.e. column) being edited. @@ -30,7 +35,7 @@ export class FormulaEditor extends NewBaseEditor { private _dom: HTMLElement; private _editorPlacement: EditorPlacement; - constructor(options: Options) { + constructor(options: IFormulaEditorOptions) { super(options); this._formulaEditor = AceEditor.create({ // A bit awkward, but we need to assume calcSize is not used until attach() has been called @@ -49,6 +54,7 @@ export class FormulaEditor extends NewBaseEditor { this.autoDispose(this._formulaEditor); this._dom = dom('div.default_editor', createMobileButtons(options.commands), + options.cssClass ? dom.cls(options.cssClass) : null, // This shouldn't be needed, but needed for tests. dom.on('mousedown', (ev) => { diff --git a/app/client/widgets/NewBaseEditor.ts b/app/client/widgets/NewBaseEditor.ts index fb8a49fb..e401d834 100644 --- a/app/client/widgets/NewBaseEditor.ts +++ b/app/client/widgets/NewBaseEditor.ts @@ -38,7 +38,7 @@ export abstract class NewBaseEditor extends Disposable { * Editors and provided by FieldBuilder. TODO: remove this method once all editors have been * updated to new-style Disposables. */ - public static create(owner: IDisposableOwner|null, options: Options): NewBaseEditor; + public static create(owner: IDisposableOwner|null, options: Opt): NewBaseEditor; public static create(options: Options): NewBaseEditor; public static create(ownerOrOptions: any, options?: any): NewBaseEditor { return options ? diff --git a/app/client/widgets/TextBox.css b/app/client/widgets/TextBox.css index df5285e7..e7971a8c 100644 --- a/app/client/widgets/TextBox.css +++ b/app/client/widgets/TextBox.css @@ -13,21 +13,22 @@ color: #D0D0D0; } - .formula_field::before, .formula_field_edit::before { - content: '='; + .formula_field::before, .formula_field_edit::before, .formula_field_sidepane::before { + /* based on standard icon styles */ + content: ""; position: absolute; - left: 2px; top: 4px; - width: 12px; - height: 12px; - border-radius: 2px; - line-height: 12px; - font-family: sans-serif; - font-size: 14px; - text-align: center; - font-weight: bold; + left: 2px; + display: inline-block; + vertical-align: middle; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + -webkit-mask-image: var(--icon-FunctionResult); + width: 16px; + height: 16px; + background-color: var(--icon-color, black); cursor: pointer; - color: white; } .formula_field::before { diff --git a/app/client/widgets/TextEditor.css b/app/client/widgets/TextEditor.css index ca2d6a40..d13e2f04 100644 --- a/app/client/widgets/TextEditor.css +++ b/app/client/widgets/TextEditor.css @@ -9,10 +9,22 @@ .formula_editor { background-color: white; - padding: 2px 0 2px 18px; + padding: 4px 0 2px 21px; z-index: 10; } +/* styles specific to the formula editor in the side panel */ +.default_editor.formula_editor_sidepane { + border-radius: 3px; +} +.formula_editor_sidepane > .formula_editor { + padding: 5px 0 5px 24px; + border-radius: 3px; +} +.formula_editor_sidepane > .formula_field_edit::before, .formula_field_sidepane::before { + left: 4px; +} + .celleditor_cursor_editor { background-color: white; diff --git a/app/common/AsyncCreate.ts b/app/common/AsyncCreate.ts index b0fc2b15..4405626c 100644 --- a/app/common/AsyncCreate.ts +++ b/app/common/AsyncCreate.ts @@ -46,6 +46,22 @@ export class AsyncCreate { } } + +/** + * A simpler version of AsyncCreate: given an async function f, returns another function that will + * call f once, and cache and return its value. On failure the result is cleared, so that + * subsequent calls will attempt calling f again. + */ +export function asyncOnce(createFunc: () => Promise): () => Promise { + let value: Promise|undefined; + function clearOnError(p: Promise): Promise { + p.catch(() => { value = undefined; }); + return p; + } + return () => (value || (value = clearOnError(createFunc.call(null)))); +} + + /** * Supports a usage similar to AsyncCreate in a Map. Returns map.get(key) if it is set to a * resolved or pending promise. Otherwise, calls creator(key) to create and return a new promise,