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.
|
// Copy an anchor link for the current row to the clipboard.
|
||||||
BaseView.prototype.copyLink = async function() {
|
BaseView.prototype.copyLink = async function() {
|
||||||
const rowId = this.viewData.getRowId(this.cursor.rowIndex());
|
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) {
|
GridView.prototype._clearColumns = function(selection) {
|
||||||
const fields = selection.fields;
|
const fields = selection.fields;
|
||||||
return this.gristDoc.docModel.columns.sendTableAction(
|
return this.gristDoc.clearColumns(fields.map(f => f.colRef.peek()));
|
||||||
['BulkUpdateRecord', fields.map(f => f.colRef.peek()), {
|
|
||||||
isFormula: fields.map(f => true),
|
|
||||||
formula: fields.map(f => ''),
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
GridView.prototype._convertFormulasToData = function(selection) {
|
GridView.prototype._convertFormulasToData = function(selection) {
|
||||||
@ -449,12 +444,7 @@ GridView.prototype._convertFormulasToData = function(selection) {
|
|||||||
// prevented by ACL rules).
|
// prevented by ACL rules).
|
||||||
const fields = selection.fields.filter(f => f.column.peek().isFormula.peek());
|
const fields = selection.fields.filter(f => f.column.peek().isFormula.peek());
|
||||||
if (!fields.length) { return null; }
|
if (!fields.length) { return null; }
|
||||||
return this.gristDoc.docModel.columns.sendTableAction(
|
return this.gristDoc.convertFormulasToData(fields.map(f => f.colRef.peek()));
|
||||||
['BulkUpdateRecord', fields.map(f => f.colRef.peek()), {
|
|
||||||
isFormula: fields.map(f => false),
|
|
||||||
formula: fields.map(f => ''),
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
GridView.prototype.selectAll = function() {
|
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() {
|
public getCsvLink() {
|
||||||
return this.docComm.docUrl('gen_csv') + '?' + encodeQueryParams({
|
return this.docComm.docUrl('gen_csv') + '?' + encodeQueryParams({
|
||||||
...this.docComm.getUrlParams(),
|
...this.docComm.getUrlParams(),
|
||||||
|
@ -14,15 +14,21 @@ const {menu, menuItem, menuText} = require('app/client/ui2018/menus');
|
|||||||
/**
|
/**
|
||||||
* Builder for the reference display multiselect.
|
* Builder for the reference display multiselect.
|
||||||
*/
|
*/
|
||||||
function RefSelect(fieldConfigTab) {
|
function RefSelect(options) {
|
||||||
this.docModel = fieldConfigTab.gristDoc.docModel;
|
this.docModel = options.docModel;
|
||||||
this.origColumn = fieldConfigTab.origColumn;
|
this.origColumn = options.origColumn;
|
||||||
this.colId = fieldConfigTab.colId;
|
this.colId = this.origColumn.colId;
|
||||||
this.isForeignRefCol = fieldConfigTab.isForeignRefCol;
|
|
||||||
|
// 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.
|
// Computed for the current fieldBuilder's field, if it exists.
|
||||||
this.fieldObs = this.autoDispose(ko.computed(() => {
|
this.fieldObs = this.autoDispose(ko.computed(() => {
|
||||||
let builder = fieldConfigTab.fieldBuilder();
|
let builder = options.fieldBuilder();
|
||||||
return builder ? builder.field : null;
|
return builder ? builder.field : null;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
// This module is unused except to group some modules for a webpack bundle.
|
// 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.
|
// 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';
|
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/CodeEditorPanel";
|
||||||
declare module "app/client/components/DetailView";
|
declare module "app/client/components/DetailView";
|
||||||
declare module "app/client/components/DocConfigTab";
|
declare module "app/client/components/DocConfigTab";
|
||||||
declare module "app/client/components/FieldConfigTab";
|
|
||||||
declare module "app/client/components/GridView";
|
declare module "app/client/components/GridView";
|
||||||
declare module "app/client/components/Layout";
|
declare module "app/client/components/Layout";
|
||||||
declare module "app/client/components/LayoutEditor";
|
declare module "app/client/components/LayoutEditor";
|
||||||
@ -38,10 +37,12 @@ declare module "app/client/components/BaseView" {
|
|||||||
import {Disposable} from 'app/client/lib/dispose';
|
import {Disposable} from 'app/client/lib/dispose';
|
||||||
import {KoArray} from "app/client/lib/koArray";
|
import {KoArray} from "app/client/lib/koArray";
|
||||||
import * as BaseRowModel from "app/client/models/BaseRowModel";
|
import * as BaseRowModel from "app/client/models/BaseRowModel";
|
||||||
|
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||||
import {LazyArrayModel} from "app/client/models/DataTableModel";
|
import {LazyArrayModel} from "app/client/models/DataTableModel";
|
||||||
import * as DataTableModel from "app/client/models/DataTableModel";
|
import * as DataTableModel from "app/client/models/DataTableModel";
|
||||||
import {ViewFieldRec, ViewSectionRec} from "app/client/models/DocModel";
|
import {ViewFieldRec, ViewSectionRec} from "app/client/models/DocModel";
|
||||||
import {SortedRowSet} from 'app/client/models/rowset';
|
import {SortedRowSet} from 'app/client/models/rowset';
|
||||||
|
import {FieldBuilder} from "app/client/widgets/FieldBuilder";
|
||||||
import {DomArg} from 'grainjs';
|
import {DomArg} from 'grainjs';
|
||||||
import {IOpenController} from 'popweasel';
|
import {IOpenController} from 'popweasel';
|
||||||
|
|
||||||
@ -53,7 +54,7 @@ declare module "app/client/components/BaseView" {
|
|||||||
public gristDoc: GristDoc;
|
public gristDoc: GristDoc;
|
||||||
public cursor: Cursor;
|
public cursor: Cursor;
|
||||||
public sortedRows: SortedRowSet;
|
public sortedRows: SortedRowSet;
|
||||||
public activeFieldBuilder: ko.Computed<unknown>;
|
public activeFieldBuilder: ko.Computed<FieldBuilder>;
|
||||||
public disableEditing: ko.Computed<boolean>;
|
public disableEditing: ko.Computed<boolean>;
|
||||||
public isTruncated: ko.Observable<boolean>;
|
public isTruncated: ko.Observable<boolean>;
|
||||||
protected tableModel: DataTableModel;
|
protected tableModel: DataTableModel;
|
||||||
@ -65,29 +66,31 @@ declare module "app/client/components/BaseView" {
|
|||||||
public getLoadingDonePromise(): Promise<void>;
|
public getLoadingDonePromise(): Promise<void>;
|
||||||
public onResize(): void;
|
public onResize(): void;
|
||||||
public prepareToPrint(onOff: boolean): void;
|
public prepareToPrint(onOff: boolean): void;
|
||||||
|
public moveEditRowToCursor(): DataRowModel;
|
||||||
}
|
}
|
||||||
export = BaseView;
|
export = BaseView;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "app/client/components/FieldConfigTab" {
|
declare module "app/client/components/RefSelect" {
|
||||||
import {GristDoc, TabContent} from 'app/client/components/GristDoc';
|
import {GristDoc, TabContent} from 'app/client/components/GristDoc';
|
||||||
import {Disposable} from 'app/client/lib/dispose';
|
import {Disposable} from 'app/client/lib/dispose';
|
||||||
|
import {ColumnRec} from "app/client/models/DocModel";
|
||||||
import {DomArg} from 'grainjs';
|
import {DomArg} from 'grainjs';
|
||||||
|
import {DocModel} from "app/client/models/DocModel";
|
||||||
|
import {FieldBuilder} from "app/client/widgets/FieldBuilder";
|
||||||
|
|
||||||
namespace FieldConfigTab {}
|
namespace RefSelect {}
|
||||||
class FieldConfigTab extends Disposable {
|
class RefSelect extends Disposable {
|
||||||
public isForeignRefCol: ko.Computed<boolean>;
|
public isForeignRefCol: ko.Computed<boolean>;
|
||||||
public refSelect: any;
|
|
||||||
|
|
||||||
constructor(options: {gristDoc: GristDoc, fieldBuilder: unknown, contentCallback: unknown});
|
constructor(options: {
|
||||||
public buildConfigDomObj(): TabContent[];
|
docModel: DocModel,
|
||||||
// TODO: these should be made private or renamed.
|
origColumn: ColumnRec,
|
||||||
public _buildNameDom(): DomArg;
|
fieldBuilder: ko.Computed<FieldBuilder|null>,
|
||||||
public _buildFormulaDom(): DomArg;
|
});
|
||||||
public _buildTransformDom(): DomArg;
|
public buildDom(): HTMLElement;
|
||||||
public _buildFormatDom(): DomArg;
|
|
||||||
}
|
}
|
||||||
export = FieldConfigTab;
|
export = RefSelect;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "app/client/components/ViewConfigTab" {
|
declare module "app/client/components/ViewConfigTab" {
|
||||||
|
@ -32,8 +32,9 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
|
|||||||
// The column's display column
|
// The column's display column
|
||||||
_displayColModel: ko.Computed<ColumnRec>;
|
_displayColModel: ko.Computed<ColumnRec>;
|
||||||
|
|
||||||
disableModify: ko.Computed<boolean>;
|
disableModifyBase: ko.Computed<boolean>; // True if column config can't be modified (name, type, etc.)
|
||||||
disableEditData: ko.Computed<boolean>;
|
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>;
|
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.disableEditData = ko.pureComputed(() => Boolean(this.summarySourceCol()));
|
||||||
|
|
||||||
this.isHiddenCol = ko.pureComputed(() => gristTypes.isHiddenCol(this.colId()));
|
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 commands from 'app/client/components/commands';
|
||||||
import * as FieldConfigTab from 'app/client/components/FieldConfigTab';
|
|
||||||
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
|
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 * as ViewConfigTab from 'app/client/components/ViewConfigTab';
|
||||||
|
import {domAsync} from 'app/client/lib/domAsync';
|
||||||
import * as imports from 'app/client/lib/imports';
|
import * as imports from 'app/client/lib/imports';
|
||||||
import {createSessionObs} from 'app/client/lib/sessionObs';
|
import {createSessionObs} from 'app/client/lib/sessionObs';
|
||||||
import {reportError} from 'app/client/models/AppModel';
|
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 {IconName} from 'app/client/ui2018/IconList';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {select} from 'app/client/ui2018/menus';
|
import {select} from 'app/client/ui2018/menus';
|
||||||
|
import {FieldBuilder} from 'app/client/widgets/FieldBuilder';
|
||||||
import {StringUnion} from 'app/common/StringUnion';
|
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';
|
DomElementArg, DomElementMethod, IDomComponent} from 'grainjs';
|
||||||
import {MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
import {MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
@ -180,40 +182,53 @@ export class RightPanel extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _buildFieldContent(owner: MultiHolder) {
|
private _buildFieldContent(owner: MultiHolder) {
|
||||||
const obs: Observable<null|FieldConfigTab> = Observable.create(owner, null);
|
const fieldBuilder = owner.autoDispose(ko.computed(() => {
|
||||||
const fieldBuilder = this.autoDispose(ko.computed(() => {
|
|
||||||
const vsi = this._gristDoc.viewModel.activeSection().viewInstance();
|
const vsi = this._gristDoc.viewModel.activeSection().viewInstance();
|
||||||
return vsi && vsi.activeFieldBuilder();
|
return vsi && vsi.activeFieldBuilder();
|
||||||
}));
|
}));
|
||||||
const gristDoc = this._gristDoc;
|
|
||||||
const content = Observable.create<TabContent[]>(owner, []);
|
const docModel = this._gristDoc.docModel;
|
||||||
const contentCallback = (tabs: TabContent[]) => content.set(tabs);
|
const origColRef = owner.autoDispose(ko.computed(() => fieldBuilder()?.origColumn.origColRef() || 0));
|
||||||
imports.loadViewPane()
|
const origColumn = owner.autoDispose(docModel.columns.createFloatingRowModel(origColRef));
|
||||||
.then(ViewPane => {
|
const isColumnValid = owner.autoDispose(ko.computed(() => Boolean(origColRef())));
|
||||||
if (owner.isDisposed()) { return; }
|
|
||||||
const fct = owner.autoDispose(ViewPane.FieldConfigTab.create({gristDoc, fieldBuilder, contentCallback}));
|
// Builder for the reference display column multiselect.
|
||||||
obs.set(fct);
|
const refSelect = owner.autoDispose(RefSelect.create({docModel, origColumn, fieldBuilder}));
|
||||||
})
|
|
||||||
.catch(reportError);
|
return domAsync(imports.loadViewPane().then(ViewPane => {
|
||||||
return dom.maybe(obs, (fct) =>
|
const {buildNameConfig, buildFormulaConfig} = ViewPane.FieldConfig;
|
||||||
buildConfigContainer(
|
return dom.maybe(isColumnValid, () =>
|
||||||
cssLabel('COLUMN TITLE'),
|
buildConfigContainer(
|
||||||
fct._buildNameDom(),
|
dom.create(buildNameConfig, origColumn),
|
||||||
fct._buildFormulaDom(),
|
cssSeparator(),
|
||||||
cssSeparator(),
|
dom.create(buildFormulaConfig, origColumn, this._gristDoc, this._activateFormulaEditor.bind(this)),
|
||||||
cssLabel('COLUMN TYPE'),
|
cssSeparator(),
|
||||||
fct._buildFormatDom(),
|
cssLabel('COLUMN TYPE'),
|
||||||
cssSeparator(),
|
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [
|
||||||
dom.maybe(fct.isForeignRefCol, () => [
|
builder.buildSelectTypeDom(),
|
||||||
cssLabel('Add referenced columns'),
|
builder.buildSelectWidgetDom(),
|
||||||
cssRow(fct.refSelect.buildDom()),
|
builder.buildConfigDom()
|
||||||
cssSeparator()
|
]),
|
||||||
]),
|
cssSeparator(),
|
||||||
cssLabel('TRANSFORM'),
|
dom.maybe(refSelect.isForeignRefCol, () => [
|
||||||
fct._buildTransformDom(),
|
cssLabel('Add referenced columns'),
|
||||||
this._disableIfReadonly(),
|
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) {
|
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(
|
return cssConfigContainer(
|
||||||
// The `position: relative;` style is needed for the overlay for the readonly mode. Note that
|
// 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
|
// 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', `
|
const cssSeparator = styled('div', `
|
||||||
border-bottom: 1px solid ${colors.mediumGrey};
|
border-bottom: 1px solid ${colors.mediumGrey};
|
||||||
margin-top: 24px;
|
margin-top: 16px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssConfigContainer = styled('div', `
|
const cssConfigContainer = styled('div', `
|
||||||
@ -681,6 +696,6 @@ const cssListItem = styled('li', `
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssTextInput = styled(textInput, `
|
export const cssTextInput = styled(textInput, `
|
||||||
flex: 1 0 auto;
|
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 { IOptionFull, menu, select } from 'app/client/ui2018/menus';
|
||||||
import { DiffBox } from 'app/client/widgets/DiffBox';
|
import { DiffBox } from 'app/client/widgets/DiffBox';
|
||||||
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
|
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 { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
|
||||||
import * as UserType from 'app/client/widgets/UserType';
|
import * as UserType from 'app/client/widgets/UserType';
|
||||||
import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl';
|
import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl';
|
||||||
@ -213,7 +213,7 @@ export class FieldBuilder extends Disposable {
|
|||||||
cssRow(
|
cssRow(
|
||||||
grainjsDom.autoDispose(selectType),
|
grainjsDom.autoDispose(selectType),
|
||||||
select(selectType, this.availableTypes, {
|
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)
|
use(this.isCallPending)
|
||||||
}),
|
}),
|
||||||
testId('type-select')
|
testId('type-select')
|
||||||
@ -301,7 +301,7 @@ export class FieldBuilder extends Disposable {
|
|||||||
dom('span.glyphicon.glyphicon-flash'),
|
dom('span.glyphicon.glyphicon-flash'),
|
||||||
dom.testId("FieldBuilder_editTransform"),
|
dom.testId("FieldBuilder_editTransform"),
|
||||||
kd.toggleClass('disabled', () => this.isTransformingType() || this.origColumn.isFormula() ||
|
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: {
|
public buildEditorDom(editRow: DataRowModel, mainRowModel: DataRowModel, options: {
|
||||||
init?: string
|
init?: string
|
||||||
}) {
|
}) {
|
||||||
@ -495,8 +494,20 @@ export class FieldBuilder extends Disposable {
|
|||||||
this.gristDoc.fieldEditorHolder.autoDispose(fieldEditor);
|
this.gristDoc.fieldEditorHolder.autoDispose(fieldEditor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public isEditorActive() {
|
public isEditorActive() {
|
||||||
return !this._fieldEditorHolder.isEmpty();
|
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 {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
|
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
|
||||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||||
|
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||||
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 {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 {asyncOnce} from "app/common/AsyncCreate";
|
||||||
import {CellValue} from "app/common/DocActions";
|
import {CellValue} from "app/common/DocActions";
|
||||||
import {isRaisedException} from 'app/common/gristTypes';
|
import {isRaisedException} from 'app/common/gristTypes';
|
||||||
import * as gutil from 'app/common/gutil';
|
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');
|
import isEqual = require('lodash/isEqual');
|
||||||
|
|
||||||
type IEditorConstructor = typeof NewBaseEditor;
|
type IEditorConstructor = typeof NewBaseEditor;
|
||||||
@ -53,7 +55,7 @@ export class FieldEditor extends Disposable {
|
|||||||
private _editCommands: IEditorCommandGroup;
|
private _editCommands: IEditorCommandGroup;
|
||||||
private _editorCtor: IEditorConstructor;
|
private _editorCtor: IEditorConstructor;
|
||||||
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
|
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
|
||||||
private _saveEditPromise: Promise<boolean>|null = null;
|
private _saveEdit = asyncOnce(() => this._doSaveEdit());
|
||||||
|
|
||||||
constructor(options: {
|
constructor(options: {
|
||||||
gristDoc: GristDoc,
|
gristDoc: GristDoc,
|
||||||
@ -116,18 +118,7 @@ export class FieldEditor extends Disposable {
|
|||||||
this._offerToMakeFormula();
|
this._offerToMakeFormula();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whenever focus returns to the Clipboard component, close the editor by saving the value.
|
setupEditorCleanup(this, this._gristDoc, this._field, this._saveEdit);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cursorPos refers to the position of the caret within the editor.
|
// 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.
|
// we defer this mode until the user types something.
|
||||||
this._field.editingFormula(isFormula && editValue !== undefined);
|
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.
|
// Replace the item in the Holder with a new one, disposing the previous one.
|
||||||
const editor = this._editorHolder.autoDispose(editorCtor.create({
|
const editor = this._editorHolder.autoDispose(editorCtor.create({
|
||||||
gristDoc: this._gristDoc,
|
gristDoc: this._gristDoc,
|
||||||
field: this._field,
|
field: this._field,
|
||||||
cellValue,
|
cellValue,
|
||||||
formulaError,
|
formulaError: getFormulaError(this._gristDoc, this._editRow, column),
|
||||||
editValue,
|
editValue,
|
||||||
cursorPos,
|
cursorPos,
|
||||||
commands: this._editCommands,
|
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
|
// 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.
|
// record got reordered (i.e. the cursor jumped), or when editing a formula.
|
||||||
private async _doSaveEdit(): Promise<boolean> {
|
private async _doSaveEdit(): Promise<boolean> {
|
||||||
@ -269,3 +245,94 @@ export class FieldEditor extends Disposable {
|
|||||||
return isFormula || (saveIndex !== cursor.rowIndex());
|
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.
|
// How wide to expand the FormulaEditor when an error is shown in it.
|
||||||
const minFormulaErrorWidth = 400;
|
const minFormulaErrorWidth = 400;
|
||||||
|
|
||||||
|
export interface IFormulaEditorOptions extends Options {
|
||||||
|
cssClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Required parameters:
|
* Required parameters:
|
||||||
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
|
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
|
||||||
@ -30,7 +35,7 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
private _dom: HTMLElement;
|
private _dom: HTMLElement;
|
||||||
private _editorPlacement: EditorPlacement;
|
private _editorPlacement: EditorPlacement;
|
||||||
|
|
||||||
constructor(options: Options) {
|
constructor(options: IFormulaEditorOptions) {
|
||||||
super(options);
|
super(options);
|
||||||
this._formulaEditor = AceEditor.create({
|
this._formulaEditor = AceEditor.create({
|
||||||
// A bit awkward, but we need to assume calcSize is not used until attach() has been called
|
// 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.autoDispose(this._formulaEditor);
|
||||||
this._dom = dom('div.default_editor',
|
this._dom = dom('div.default_editor',
|
||||||
createMobileButtons(options.commands),
|
createMobileButtons(options.commands),
|
||||||
|
options.cssClass ? dom.cls(options.cssClass) : null,
|
||||||
|
|
||||||
// This shouldn't be needed, but needed for tests.
|
// This shouldn't be needed, but needed for tests.
|
||||||
dom.on('mousedown', (ev) => {
|
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
|
* Editors and provided by FieldBuilder. TODO: remove this method once all editors have been
|
||||||
* updated to new-style Disposables.
|
* 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(options: Options): NewBaseEditor;
|
||||||
public static create(ownerOrOptions: any, options?: any): NewBaseEditor {
|
public static create(ownerOrOptions: any, options?: any): NewBaseEditor {
|
||||||
return options ?
|
return options ?
|
||||||
|
@ -13,21 +13,22 @@
|
|||||||
color: #D0D0D0;
|
color: #D0D0D0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formula_field::before, .formula_field_edit::before {
|
.formula_field::before, .formula_field_edit::before, .formula_field_sidepane::before {
|
||||||
content: '=';
|
/* based on standard icon styles */
|
||||||
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 2px;
|
|
||||||
top: 4px;
|
top: 4px;
|
||||||
width: 12px;
|
left: 2px;
|
||||||
height: 12px;
|
display: inline-block;
|
||||||
border-radius: 2px;
|
vertical-align: middle;
|
||||||
line-height: 12px;
|
-webkit-mask-repeat: no-repeat;
|
||||||
font-family: sans-serif;
|
-webkit-mask-position: center;
|
||||||
font-size: 14px;
|
-webkit-mask-size: contain;
|
||||||
text-align: center;
|
-webkit-mask-image: var(--icon-FunctionResult);
|
||||||
font-weight: bold;
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-color: var(--icon-color, black);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.formula_field::before {
|
.formula_field::before {
|
||||||
|
@ -9,10 +9,22 @@
|
|||||||
|
|
||||||
.formula_editor {
|
.formula_editor {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
padding: 2px 0 2px 18px;
|
padding: 4px 0 2px 21px;
|
||||||
z-index: 10;
|
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 {
|
.celleditor_cursor_editor {
|
||||||
background-color: white;
|
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
|
* 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,
|
* resolved or pending promise. Otherwise, calls creator(key) to create and return a new promise,
|
||||||
|
Loading…
Reference in New Issue
Block a user