mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Update UI for formula and column label/id in the right-side panel.
Summary: - Update styling of label, id, and "derived ID from label" checkbox. - Implement a label which shows 'Data Column' vs 'Formula Column' vs 'Empty Column', and a dropdown with column actions (such as Clear/Convert) - Implement new formula display in the side-panel, and open the standard FormulaEditor when clicked. - Remove old FieldConfigTab, of which now very little would be used. - Fix up remaining code that relied on it (RefSelect) Test Plan: Fixed old tests, added new browser cases, and a case for a new helper function. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2757
This commit is contained in:
parent
e2d3b70509
commit
b4c34cedad
@ -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());
|
||||
|
@ -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;
|
@ -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() {
|
||||
|
@ -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<void> {
|
||||
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<void> {
|
||||
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(),
|
||||
|
@ -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;
|
||||
}));
|
||||
|
||||
|
@ -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};
|
||||
|
31
app/client/declarations.d.ts
vendored
31
app/client/declarations.d.ts
vendored
@ -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<unknown>;
|
||||
public activeFieldBuilder: ko.Computed<FieldBuilder>;
|
||||
public disableEditing: ko.Computed<boolean>;
|
||||
public isTruncated: ko.Observable<boolean>;
|
||||
protected tableModel: DataTableModel;
|
||||
@ -65,29 +66,31 @@ declare module "app/client/components/BaseView" {
|
||||
public getLoadingDonePromise(): Promise<void>;
|
||||
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<boolean>;
|
||||
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<FieldBuilder|null>,
|
||||
});
|
||||
public buildDom(): HTMLElement;
|
||||
}
|
||||
export = FieldConfigTab;
|
||||
export = RefSelect;
|
||||
}
|
||||
|
||||
declare module "app/client/components/ViewConfigTab" {
|
||||
|
@ -32,8 +32,9 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
|
||||
// The column's display column
|
||||
_displayColModel: ko.Computed<ColumnRec>;
|
||||
|
||||
disableModify: ko.Computed<boolean>;
|
||||
disableEditData: ko.Computed<boolean>;
|
||||
disableModifyBase: ko.Computed<boolean>; // True if column config can't be modified (name, type, etc.)
|
||||
disableModify: ko.Computed<boolean>; // True if column can't be modified or is being transformed.
|
||||
disableEditData: ko.Computed<boolean>; // True to disable editing of the data in this column.
|
||||
|
||||
isHiddenCol: ko.Computed<boolean>;
|
||||
|
||||
@ -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()));
|
||||
|
73
app/client/ui/CodeHighlight.ts
Normal file
73
app/client/ui/CodeHighlight.ts
Normal file
@ -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<string>, 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;
|
||||
}
|
||||
`);
|
197
app/client/ui/FieldConfig.ts
Normal file
197
app/client/ui/FieldConfig.ts
Normal file
@ -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;
|
||||
`);
|
@ -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<null|FieldConfigTab> = 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<TabContent[]>(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|null>(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|null>(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;
|
||||
`);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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<NewBaseEditor> = Holder.create(this);
|
||||
private _saveEditPromise: Promise<boolean>|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<CellValue>|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<boolean> {
|
||||
@ -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<unknown>
|
||||
) {
|
||||
// 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<CellValue>|undefined {
|
||||
const colId = column.colId.peek();
|
||||
let formulaError: Observable<CellValue>|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;
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -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<Opt extends Options>(owner: IDisposableOwner|null, options: Opt): NewBaseEditor;
|
||||
public static create(options: Options): NewBaseEditor;
|
||||
public static create(ownerOrOptions: any, options?: any): NewBaseEditor {
|
||||
return options ?
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
||||
|
@ -46,6 +46,22 @@ export class AsyncCreate<T> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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<T>(createFunc: () => Promise<T>): () => Promise<T> {
|
||||
let value: Promise<T>|undefined;
|
||||
function clearOnError(p: Promise<T>): Promise<T> {
|
||||
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,
|
||||
|
Loading…
Reference in New Issue
Block a user