(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:
Dmitry S 2021-03-16 23:45:44 -04:00
parent e2d3b70509
commit b4c34cedad
18 changed files with 554 additions and 292 deletions

View File

@ -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());

View File

@ -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;

View File

@ -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() {

View File

@ -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(),

View File

@ -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;
})); }));

View File

@ -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};

View File

@ -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" {

View File

@ -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()));

View 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;
}
`);

View 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;
`);

View File

@ -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;
`); `);

View File

@ -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);
}
} }

View File

@ -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;
}

View File

@ -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) => {

View File

@ -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 ?

View File

@ -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 {

View File

@ -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;

View File

@ -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,