mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Conditional formatting rules
Summary: Adding conditional formatting rules feature. Each column can have multiple styling rules which are applied in order when evaluated to a truthy value. - The creator panel has a new section: Cell Style - New user action AddEmptyRule for adding an empty rule - New columns in _grist_Table_columns and fields A new color picker will be introduced in a follow-up diff (as it is also used in choice/choice list/filters). Design document: https://grist.quip.com/FVzfAgoO5xOF/Conditional-Formatting-Implementation-Design Test Plan: new tests Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3282
This commit is contained in:
239
app/client/widgets/CellStyle.ts
Normal file
239
app/client/widgets/CellStyle.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ColumnRec} from 'app/client/models/DocModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
||||
import {Style} from 'app/client/models/Styles';
|
||||
import {cssFieldFormula} from 'app/client/ui/FieldConfig';
|
||||
import {cssIcon, cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {textButton} from 'app/client/ui2018/buttons';
|
||||
import {colorSelect} from 'app/client/ui2018/ColorSelect';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {setupEditorCleanup} from 'app/client/widgets/FieldEditor';
|
||||
import {cssError, openFormulaEditor} from 'app/client/widgets/FormulaEditor';
|
||||
import {isRaisedException, isValidRuleValue} from 'app/common/gristTypes';
|
||||
import {RowRecord} from 'app/plugin/GristData';
|
||||
import {Computed, Disposable, dom, DomContents, fromKo, makeTestId, MultiHolder, Observable, styled} from 'grainjs';
|
||||
import debounce = require('lodash/debounce');
|
||||
|
||||
const testId = makeTestId('test-widget-style-');
|
||||
|
||||
export class CellStyle extends Disposable {
|
||||
protected textColor: Observable<string>;
|
||||
protected fillColor: Observable<string>;
|
||||
// Holds data from currently selected record (holds data only when this field has conditional styles).
|
||||
protected currentRecord: Computed<RowRecord | undefined>;
|
||||
// Helper field for refreshing current record data.
|
||||
protected dataChangeTrigger = Observable.create(this, 0);
|
||||
|
||||
constructor(
|
||||
protected field: ViewFieldRec,
|
||||
protected gristDoc: GristDoc,
|
||||
defaultTextColor: string = '#000000'
|
||||
) {
|
||||
super();
|
||||
this.textColor = Computed.create(
|
||||
this,
|
||||
use => use(this.field.textColor) || defaultTextColor
|
||||
).onWrite(val => this.field.textColor(val === defaultTextColor ? '' : val));
|
||||
this.fillColor = fromKo(this.field.fillColor);
|
||||
this.currentRecord = Computed.create(this, use => {
|
||||
if (!use(this.field.hasRules)) {
|
||||
return;
|
||||
}
|
||||
// As we are not subscribing to data change, we will monitor actions
|
||||
// that are sent from the server to refresh this computed observable.
|
||||
void use(this.dataChangeTrigger);
|
||||
const tableId = use(use(use(field.column).table).tableId);
|
||||
const tableData = gristDoc.docData.getTable(tableId)!;
|
||||
const cursor = use(gristDoc.cursorPosition);
|
||||
// Make sure we are not on the new row.
|
||||
if (!cursor || typeof cursor.rowId !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
return tableData.getRecord(cursor.rowId);
|
||||
});
|
||||
|
||||
// Here we will subscribe to tableActionEmitter, and update currentRecord observable.
|
||||
// We have 'dataChangeTrigger' that is just a number that will be updated every time
|
||||
// we received some table actions.
|
||||
const debouncedUpdate = debounce(() => {
|
||||
if (this.dataChangeTrigger.isDisposed()) {
|
||||
return;
|
||||
}
|
||||
this.dataChangeTrigger.set(this.dataChangeTrigger.get() + 1);
|
||||
}, 0);
|
||||
Computed.create(this, (use) => {
|
||||
const tableId = use(use(use(field.column).table).tableId);
|
||||
const tableData = gristDoc.docData.getTable(tableId);
|
||||
return tableData ? use.owner.autoDispose(tableData.tableActionEmitter.addListener(debouncedUpdate)) : null;
|
||||
});
|
||||
}
|
||||
|
||||
public buildDom(): DomContents {
|
||||
const holder = new MultiHolder();
|
||||
return [
|
||||
cssLabel('CELL STYLE', dom.autoDispose(holder)),
|
||||
cssRow(
|
||||
colorSelect(
|
||||
this.textColor,
|
||||
this.fillColor,
|
||||
// Calling `field.widgetOptionsJson.save()` saves both fill and text color settings.
|
||||
() => this.field.widgetOptionsJson.save()
|
||||
)
|
||||
),
|
||||
cssRow(
|
||||
{style: 'margin-top: 16px'},
|
||||
textButton(
|
||||
'Add conditional style',
|
||||
testId('add-conditional-style'),
|
||||
dom.on('click', () => this.field.addEmptyRule())
|
||||
),
|
||||
dom.hide(this.field.hasRules)
|
||||
),
|
||||
dom.domComputedOwned(
|
||||
use => use(this.field.rulesCols),
|
||||
(owner, rules) =>
|
||||
cssRuleList(
|
||||
dom.show(rules.length > 0),
|
||||
...rules.map((column, ruleIndex) => {
|
||||
const textColor = this._buildStyleOption(owner, ruleIndex, 'textColor');
|
||||
const fillColor = this._buildStyleOption(owner, ruleIndex, 'fillColor');
|
||||
const save = async () => {
|
||||
// This will save both options.
|
||||
await this.field.rulesStyles.save();
|
||||
};
|
||||
const currentValue = Computed.create(owner, use => {
|
||||
const record = use(this.currentRecord);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
const value = record[use(column.colId)];
|
||||
return value;
|
||||
});
|
||||
const hasError = Computed.create(owner, use => {
|
||||
return !isValidRuleValue(use(currentValue));
|
||||
});
|
||||
const errorMessage = Computed.create(owner, use => {
|
||||
const value = use(currentValue);
|
||||
return (!use(hasError) ? '' :
|
||||
isRaisedException(value) ? 'Error in style rule' :
|
||||
'Rule must return True or False');
|
||||
});
|
||||
return dom('div',
|
||||
testId(`conditional-rule-${ruleIndex}`),
|
||||
testId(`conditional-rule`), // for testing
|
||||
cssLineLabel('IF...'),
|
||||
cssColumnsRow(
|
||||
cssLeftColumn(
|
||||
this._buildRuleFormula(column.formula, column, hasError),
|
||||
cssRuleError(
|
||||
dom.text(errorMessage),
|
||||
dom.show(hasError),
|
||||
testId(`rule-error-${ruleIndex}`),
|
||||
),
|
||||
colorSelect(textColor, fillColor, save, true)
|
||||
),
|
||||
cssRemoveButton(
|
||||
'Remove',
|
||||
testId(`remove-rule-${ruleIndex}`),
|
||||
dom.on('click', () => this.field.removeRule(ruleIndex))
|
||||
)
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
),
|
||||
cssRow(
|
||||
textButton('Add another rule'),
|
||||
testId('add-another-rule'),
|
||||
dom.on('click', () => this.field.addEmptyRule()),
|
||||
dom.show(this.field.hasRules)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _buildStyleOption(owner: Disposable, index: number, option: keyof Style) {
|
||||
const obs = Computed.create(owner, use => use(this.field.rulesStyles)[index]?.[option]);
|
||||
obs.onWrite(value => {
|
||||
const list = Array.from(this.field.rulesStyles.peek() ?? []);
|
||||
list[index] = list[index] ?? {};
|
||||
list[index][option] = value;
|
||||
this.field.rulesStyles(list);
|
||||
});
|
||||
return obs;
|
||||
}
|
||||
|
||||
private _buildRuleFormula(
|
||||
formula: KoSaveableObservable<string>,
|
||||
column: ColumnRec,
|
||||
hasError: Observable<boolean>
|
||||
) {
|
||||
return cssFieldFormula(
|
||||
formula,
|
||||
{maxLines: 1},
|
||||
dom.cls('formula_field_sidepane'),
|
||||
dom.cls(cssErrorBorder.className, hasError),
|
||||
{tabIndex: '-1'},
|
||||
dom.on('focus', (_, refElem) => {
|
||||
const vsi = this.gristDoc.viewModel.activeSection().viewInstance();
|
||||
const editorHolder = openFormulaEditor({
|
||||
gristDoc: this.gristDoc,
|
||||
field: this.field,
|
||||
column,
|
||||
editRow: vsi?.moveEditRowToCursor(),
|
||||
refElem,
|
||||
setupCleanup: setupEditorCleanup,
|
||||
});
|
||||
// Add editor to document holder - this will prevent multiple formula editor instances.
|
||||
this.gristDoc.fieldEditorHolder.autoDispose(editorHolder);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cssRemoveButton = styled(cssIcon, `
|
||||
flex: none;
|
||||
margin: 6px;
|
||||
margin-right: 0px;
|
||||
transform: translateY(4px);
|
||||
cursor: pointer;
|
||||
--icon-color: ${colors.slate};
|
||||
&:hover {
|
||||
--icon-color: ${colors.lightGreen};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssLineLabel = styled(cssLabel, `
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
`);
|
||||
|
||||
const cssRuleList = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 12px;
|
||||
`);
|
||||
|
||||
const cssErrorBorder = styled('div', `
|
||||
border-color: ${colors.error};
|
||||
`);
|
||||
|
||||
const cssRuleError = styled(cssError, `
|
||||
margin: 2px 0px 10px 0px;
|
||||
`);
|
||||
|
||||
const cssColumnsRow = styled(cssRow, `
|
||||
align-items: flex-start;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
`);
|
||||
|
||||
const cssLeftColumn = styled('div', `
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`);
|
||||
@@ -13,13 +13,16 @@ import { reportError } from 'app/client/models/AppModel';
|
||||
import { DataRowModel } from 'app/client/models/DataRowModel';
|
||||
import { ColumnRec, DocModel, ViewFieldRec } from 'app/client/models/DocModel';
|
||||
import { SaveableObjObservable, setSaveValue } from 'app/client/models/modelUtil';
|
||||
import { CombinedStyle, Style } from 'app/client/models/Styles';
|
||||
import { FieldSettingsMenu } from 'app/client/ui/FieldMenus';
|
||||
import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanel';
|
||||
import { buttonSelect } from 'app/client/ui2018/buttonSelect';
|
||||
import { colors } from 'app/client/ui2018/cssVars';
|
||||
import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
|
||||
import { DiffBox } from 'app/client/widgets/DiffBox';
|
||||
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
|
||||
import { FieldEditor, openFormulaEditor, saveWithoutEditor, setupEditorCleanup } from 'app/client/widgets/FieldEditor';
|
||||
import { FieldEditor, saveWithoutEditor, setupEditorCleanup } from 'app/client/widgets/FieldEditor';
|
||||
import { openFormulaEditor } from 'app/client/widgets/FormulaEditor';
|
||||
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
|
||||
import { NewBaseEditor } from "app/client/widgets/NewBaseEditor";
|
||||
import * as UserType from 'app/client/widgets/UserType';
|
||||
@@ -337,7 +340,8 @@ export class FieldBuilder extends Disposable {
|
||||
kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
|
||||
dom('div',
|
||||
widget.buildConfigDom(),
|
||||
widget.buildColorConfigDom(),
|
||||
cssSeparator(),
|
||||
widget.buildColorConfigDom(this.gristDoc),
|
||||
|
||||
// If there is more than one field for this column (i.e. present in multiple views).
|
||||
kd.maybe(() => this.origColumn.viewFields().all().length > 1, () =>
|
||||
@@ -414,6 +418,35 @@ export class FieldBuilder extends Disposable {
|
||||
* buildEditorDom functions of its widgetImpl.
|
||||
*/
|
||||
public buildDomWithCursor(row: DataRowModel, isActive: boolean, isSelected: boolean) {
|
||||
const computedFlags = koUtil.withKoUtils(ko.pureComputed(() => {
|
||||
return this.field.rulesColsIds().map(colRef => row.cells[colRef]?.() ?? false);
|
||||
}, this).extend({ deferred: true }));
|
||||
// Here we are using computedWithPrevious helper, to return
|
||||
// the previous value of computed rule. When user adds or deletes
|
||||
// rules there is a brief moment that rule is still not evaluated
|
||||
// (rules.length != value.length), in this case return last value
|
||||
// and wait for the update.
|
||||
const computedRule = koUtil.withKoUtils(ko.pureComputed(() => {
|
||||
if (this.isDisposed()) { return null; }
|
||||
const styles: Style[] = this.field.rulesStyles();
|
||||
// Make sure that rules where computed.
|
||||
if (!Array.isArray(styles) || styles.length === 0) { return null; }
|
||||
const flags = computedFlags();
|
||||
// Make extra sure that all rules are up to date.
|
||||
// If not, fallback to the previous value.
|
||||
// We need to make sure that all rules columns are created,
|
||||
// sometimes there are more styles for a brief moment.
|
||||
if (styles.length < flags.length) { return/* undefined */; }
|
||||
// We will combine error information in the same computed value.
|
||||
// If there is an error in rules - return it instead of the style.
|
||||
const error = flags.some(f => !gristTypes.isValidRuleValue(f));
|
||||
if (error) {
|
||||
return { error };
|
||||
}
|
||||
// Combine them into a single style option.
|
||||
return { style : new CombinedStyle(styles, flags) };
|
||||
}, this).extend({ deferred: true })).previousOnUndefined();
|
||||
|
||||
const widgetObs = koUtil.withKoUtils(ko.computed(function() {
|
||||
// TODO: Accessing row values like this doesn't always work (row and field might not be updated
|
||||
// simultaneously).
|
||||
@@ -429,11 +462,29 @@ export class FieldBuilder extends Disposable {
|
||||
}
|
||||
}, this).extend({ deferred: true })).onlyNotifyUnequal();
|
||||
|
||||
const textColor = koUtil.withKoUtils(ko.computed(function() {
|
||||
if (this.isDisposed()) { return null; }
|
||||
const fromRules = computedRule()?.style?.textColor;
|
||||
return fromRules || this.field.textColor() || '';
|
||||
}, this)).onlyNotifyUnequal();
|
||||
|
||||
const background = koUtil.withKoUtils(ko.computed(function() {
|
||||
if (this.isDisposed()) { return null; }
|
||||
const fromRules = computedRule()?.style?.fillColor;
|
||||
return fromRules || this.field.fillColor();
|
||||
}, this)).onlyNotifyUnequal();
|
||||
|
||||
const errorInStyle = ko.pureComputed(() => Boolean(computedRule()?.error));
|
||||
|
||||
return (elem: Element) => {
|
||||
this._rowMap.set(row, elem);
|
||||
dom(elem,
|
||||
dom.autoDispose(widgetObs),
|
||||
dom.autoDispose(computedFlags),
|
||||
dom.autoDispose(errorInStyle),
|
||||
dom.autoDispose(textColor),
|
||||
dom.autoDispose(computedRule),
|
||||
dom.autoDispose(background),
|
||||
this._options.isPreview ? null : kd.cssClass(this.field.formulaCssClass),
|
||||
kd.toggleClass("readonly", toKo(ko, this._readonly)),
|
||||
kd.maybe(isSelected, () => dom('div.selected_cursor',
|
||||
@@ -443,8 +494,9 @@ export class FieldBuilder extends Disposable {
|
||||
if (this.isDisposed()) { return null; } // Work around JS errors during field removal.
|
||||
const cellDom = widget ? widget.buildDom(row) : buildErrorDom(row, this.field);
|
||||
return dom(cellDom, kd.toggleClass('has_cursor', isActive),
|
||||
kd.style('--grist-cell-color', () => this.field.textColor() || ''),
|
||||
kd.style('--grist-cell-background-color', this.field.fillColor));
|
||||
kd.toggleClass('field-error-from-style', errorInStyle),
|
||||
kd.style('--grist-cell-color', textColor),
|
||||
kd.style('--grist-cell-background-color', background));
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -547,3 +599,8 @@ export class FieldBuilder extends Disposable {
|
||||
const cssTypeSelectMenu = styled('div', `
|
||||
max-height: 500px;
|
||||
`);
|
||||
|
||||
const cssSeparator = styled('div', `
|
||||
border-bottom: 1px solid ${colors.mediumGrey};
|
||||
margin-top: 16px;
|
||||
`);
|
||||
|
||||
@@ -3,17 +3,15 @@ import {Cursor} from 'app/client/components/Cursor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {showTooltipToCreateFormula} from 'app/client/widgets/EditorTooltip';
|
||||
import {FormulaEditor} from 'app/client/widgets/FormulaEditor';
|
||||
import {FormulaEditor, getFormulaError} from 'app/client/widgets/FormulaEditor';
|
||||
import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
|
||||
import {asyncOnce} from "app/common/AsyncCreate";
|
||||
import {CellValue} from "app/common/DocActions";
|
||||
import {isRaisedException} from 'app/common/gristTypes';
|
||||
import * as gutil from 'app/common/gutil';
|
||||
import {Disposable, Emitter, Holder, MultiHolder, Observable} from 'grainjs';
|
||||
import {Disposable, Emitter, Holder, MultiHolder} from 'grainjs';
|
||||
import isEqual = require('lodash/isEqual');
|
||||
import {CellPosition} from "app/client/components/CellPosition";
|
||||
|
||||
@@ -372,73 +370,6 @@ export class FieldEditor extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a formula editor. Returns a Disposable that owns the editor.
|
||||
*/
|
||||
export function openFormulaEditor(options: {
|
||||
gristDoc: GristDoc,
|
||||
field: ViewFieldRec,
|
||||
// Needed to get exception value, if any.
|
||||
editRow?: DataRowModel,
|
||||
// Element over which to position the editor.
|
||||
refElem: Element,
|
||||
editValue?: string,
|
||||
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
||||
onCancel?: () => void,
|
||||
// Called after editor is created to set up editor cleanup (e.g. saving on click-away).
|
||||
setupCleanup: (
|
||||
owner: MultiHolder,
|
||||
doc: GristDoc,
|
||||
field: ViewFieldRec,
|
||||
save: () => Promise<void>
|
||||
) => void,
|
||||
}): Disposable {
|
||||
const {gristDoc, field, editRow, refElem, setupCleanup} = 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 (options.onSave) {
|
||||
await options.onSave(column, formula as string);
|
||||
} else if (formula !== column.formula.peek()) {
|
||||
await column.updateColValues({formula});
|
||||
}
|
||||
holder.dispose();
|
||||
});
|
||||
|
||||
// These are the commands for while the editor is active.
|
||||
const editCommands = {
|
||||
fieldEditSave: () => { saveEdit().catch(reportError); },
|
||||
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
|
||||
fieldEditCancel: () => { holder.dispose(); options.onCancel?.(); },
|
||||
};
|
||||
|
||||
// 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: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
|
||||
editValue: options.editValue,
|
||||
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
|
||||
commands: editCommands,
|
||||
cssClass: 'formula_editor_sidepane',
|
||||
readonly : false
|
||||
});
|
||||
editor.attach(refElem);
|
||||
|
||||
// When formula is empty enter formula-editing mode (highlight formula icons; click on a column inserts its ID).
|
||||
// This function is used for primarily for switching between different column behaviors, so we want to enter full
|
||||
// edit mode right away.
|
||||
// TODO: consider converting it to parameter, when this will be used in different scenarios.
|
||||
if (!column.formula()) {
|
||||
field.editingFormula(true);
|
||||
}
|
||||
setupCleanup(holder, gristDoc, field, saveEdit);
|
||||
return holder;
|
||||
}
|
||||
|
||||
/**
|
||||
* For an readonly editor, set up its cleanup:
|
||||
@@ -479,25 +410,3 @@ export function setupEditorCleanup(
|
||||
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();
|
||||
const cellCurrentValue = editRow.cells[colId].peek();
|
||||
const isFormula = column.isFormula() || column.hasTriggerFormula();
|
||||
if (isFormula && isRaisedException(cellCurrentValue)) {
|
||||
const formulaError = Observable.create(null, cellCurrentValue);
|
||||
gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId())
|
||||
.then(value => {
|
||||
formulaError.set(value);
|
||||
})
|
||||
.catch(reportError);
|
||||
return formulaError;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,22 @@ import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorBu
|
||||
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
|
||||
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||
import {undef} from 'app/common/gutil';
|
||||
import {Computed, dom, Observable, styled} from 'grainjs';
|
||||
import {Computed, Disposable, dom, MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
||||
import {isRaisedException} from "app/common/gristTypes";
|
||||
import {decodeObject, RaisedException} from "app/plugin/objtypes";
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ColumnRec} from 'app/client/models/DocModel';
|
||||
import {asyncOnce} from 'app/common/AsyncCreate';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {CellValue} from 'app/common/DocActions';
|
||||
import debounce = require('lodash/debounce');
|
||||
|
||||
// How wide to expand the FormulaEditor when an error is shown in it.
|
||||
const minFormulaErrorWidth = 400;
|
||||
|
||||
export interface IFormulaEditorOptions extends Options {
|
||||
cssClass?: string;
|
||||
editingFormula?: ko.Computed<boolean>,
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +47,8 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
constructor(options: IFormulaEditorOptions) {
|
||||
super(options);
|
||||
|
||||
const editingFormula = options.editingFormula || options.field.editingFormula;
|
||||
|
||||
const initialValue = undef(options.state as string | undefined, options.editValue, String(options.cellValue));
|
||||
// create editor state observable (used by draft and latest position memory)
|
||||
this.editorState = Observable.create(this, initialValue);
|
||||
@@ -59,7 +68,7 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
? Object.assign({ setCursor: this._onSetCursor }, options.commands)
|
||||
// for readonly mode don't grab cursor when clicked away - just move the cursor
|
||||
: options.commands;
|
||||
this._commandGroup = this.autoDispose(createGroup(allCommands, this, options.field.editingFormula));
|
||||
this._commandGroup = this.autoDispose(createGroup(allCommands, this, editingFormula));
|
||||
|
||||
const hideErrDetails = Observable.create(this, true);
|
||||
const raisedException = Computed.create(this, use => {
|
||||
@@ -109,14 +118,14 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
|
||||
// enable formula editing if state was passed
|
||||
if (options.state || options.readonly) {
|
||||
options.field.editingFormula(true);
|
||||
editingFormula(true);
|
||||
}
|
||||
if (options.readonly) {
|
||||
this._formulaEditor.enable(false);
|
||||
aceObj.gotoLine(0, 0); // By moving, ace editor won't highlight anything
|
||||
}
|
||||
// This catches any change to the value including e.g. via backspace or paste.
|
||||
aceObj.once("change", () => options.field.editingFormula(true));
|
||||
aceObj.once("change", () => editingFormula(true));
|
||||
})
|
||||
),
|
||||
(options.formulaError ?
|
||||
@@ -234,7 +243,150 @@ function _isInIdentifier(line: string, column: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a formula editor. Returns a Disposable that owns the editor.
|
||||
*/
|
||||
export function openFormulaEditor(options: {
|
||||
gristDoc: GristDoc,
|
||||
field: ViewFieldRec,
|
||||
// Associated formula from a diffrent column (for example style rule).
|
||||
column?: ColumnRec,
|
||||
// Needed to get exception value, if any.
|
||||
editRow?: DataRowModel,
|
||||
// Element over which to position the editor.
|
||||
refElem: Element,
|
||||
editValue?: string,
|
||||
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
||||
onCancel?: () => void,
|
||||
// Called after editor is created to set up editor cleanup (e.g. saving on click-away).
|
||||
setupCleanup: (
|
||||
owner: MultiHolder,
|
||||
doc: GristDoc,
|
||||
field: ViewFieldRec,
|
||||
save: () => Promise<void>
|
||||
) => void,
|
||||
}): Disposable {
|
||||
const {gristDoc, field, editRow, refElem, setupCleanup} = options;
|
||||
const holder = MultiHolder.create(null);
|
||||
const column = options.column ? options.column : field.origCol();
|
||||
|
||||
// AsyncOnce ensures it's called once even if triggered multiple times.
|
||||
const saveEdit = asyncOnce(async () => {
|
||||
const formula = editor.getCellValue();
|
||||
if (options.onSave) {
|
||||
await options.onSave(column, formula as string);
|
||||
} else if (formula !== column.formula.peek()) {
|
||||
await column.updateColValues({formula});
|
||||
}
|
||||
holder.dispose();
|
||||
});
|
||||
|
||||
// These are the commands for while the editor is active.
|
||||
const editCommands = {
|
||||
fieldEditSave: () => { saveEdit().catch(reportError); },
|
||||
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
|
||||
fieldEditCancel: () => { holder.dispose(); options.onCancel?.(); },
|
||||
};
|
||||
|
||||
// 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: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
|
||||
editValue: options.editValue,
|
||||
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
|
||||
commands: editCommands,
|
||||
cssClass: 'formula_editor_sidepane',
|
||||
readonly : false
|
||||
});
|
||||
editor.attach(refElem);
|
||||
|
||||
// When formula is empty enter formula-editing mode (highlight formula icons; click on a column inserts its ID).
|
||||
// This function is used for primarily for switching between different column behaviors, so we want to enter full
|
||||
// edit mode right away.
|
||||
// TODO: consider converting it to parameter, when this will be used in different scenarios.
|
||||
if (!column.formula()) {
|
||||
field.editingFormula(true);
|
||||
}
|
||||
setupCleanup(holder, gristDoc, field, saveEdit);
|
||||
return holder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function getFormulaError(
|
||||
gristDoc: GristDoc, editRow: DataRowModel, column: ColumnRec
|
||||
): Observable<CellValue>|undefined {
|
||||
const colId = column.colId.peek();
|
||||
const cellCurrentValue = editRow.cells[colId].peek();
|
||||
const isFormula = column.isFormula() || column.hasTriggerFormula();
|
||||
if (isFormula && isRaisedException(cellCurrentValue)) {
|
||||
const formulaError = Observable.create(null, cellCurrentValue);
|
||||
gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId())
|
||||
.then(value => {
|
||||
formulaError.set(value);
|
||||
})
|
||||
.catch(reportError);
|
||||
return formulaError;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and return an observable for the count of errors in a column, which gets updated in
|
||||
* response to changes in origColumn and in user data.
|
||||
*/
|
||||
export function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, origColumn: ColumnRec) {
|
||||
const errorMessage = Observable.create(owner, '');
|
||||
|
||||
// Count errors in origColumn when it's a formula column. Counts get cached by the
|
||||
// tableData.countErrors() method, and invalidated on relevant data changes.
|
||||
function countErrors() {
|
||||
if (owner.isDisposed()) { return; }
|
||||
const tableData = gristDoc.docData.getTable(origColumn.table.peek().tableId.peek());
|
||||
const isFormula = origColumn.isRealFormula.peek() || origColumn.hasTriggerFormula.peek();
|
||||
if (tableData && isFormula) {
|
||||
const colId = origColumn.colId.peek();
|
||||
const numCells = tableData.getColValues(colId)?.length || 0;
|
||||
const numErrors = tableData.countErrors(colId) || 0;
|
||||
errorMessage.set(
|
||||
(numErrors === 0) ? '' :
|
||||
(numCells === 1) ? `Error in the cell` :
|
||||
(numErrors === numCells) ? `Errors in all ${numErrors} cells` :
|
||||
`Errors in ${numErrors} of ${numCells} cells`
|
||||
);
|
||||
} else {
|
||||
errorMessage.set('');
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce the count calculation to defer it to the end of a bundle of actions.
|
||||
const debouncedCountErrors = debounce(countErrors, 0);
|
||||
|
||||
// If there is an update to the data in the table, count errors again. Since the same UI is
|
||||
// reused when different page widgets are selected, we need to re-create this subscription
|
||||
// whenever the selected table changes. We use a Computed to both react to changes and dispose
|
||||
// the previous subscription when it changes.
|
||||
Computed.create(owner, (use) => {
|
||||
const tableData = gristDoc.docData.getTable(use(use(origColumn.table).tableId));
|
||||
return tableData ? use.owner.autoDispose(tableData.tableActionEmitter.addListener(debouncedCountErrors)) : null;
|
||||
});
|
||||
|
||||
// The counts depend on the origColumn and its isRealFormula status, but with the debounced
|
||||
// callback and subscription to data, subscribe to relevant changes manually (rather than using
|
||||
// a Computed).
|
||||
owner.autoDispose(subscribe(use => { use(origColumn.id); use(origColumn.isRealFormula); debouncedCountErrors(); }));
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
const cssCollapseIcon = styled(icon, `
|
||||
margin: -3px 4px 0 4px;
|
||||
--icon-color: ${colors.slate};
|
||||
`);
|
||||
|
||||
export const cssError = styled('div', `
|
||||
color: ${colors.error};
|
||||
`);
|
||||
|
||||
@@ -3,14 +3,19 @@
|
||||
* so is friendlier and clearer to derive TypeScript classes from.
|
||||
*/
|
||||
import {DocComm} from 'app/client/components/DocComm';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {DocData} from 'app/client/models/DocData';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {SaveableObjObservable} from 'app/client/models/modelUtil';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {colorSelect} from 'app/client/ui2018/ColorSelect';
|
||||
import {CellStyle} from 'app/client/widgets/CellStyle';
|
||||
import {BaseFormatter} from 'app/common/ValueFormatter';
|
||||
import {Computed, Disposable, DomContents, fromKo, Observable} from 'grainjs';
|
||||
|
||||
import {
|
||||
Disposable,
|
||||
dom,
|
||||
DomContents,
|
||||
fromKo,
|
||||
Observable,
|
||||
} from 'grainjs';
|
||||
|
||||
export interface Options {
|
||||
// A hex value to set the default widget text color. Default to '#000000' if omitted.
|
||||
@@ -33,42 +38,33 @@ export abstract class NewAbstractWidget extends Disposable {
|
||||
protected valueFormatter: Observable<BaseFormatter>;
|
||||
protected textColor: Observable<string>;
|
||||
protected fillColor: Observable<string>;
|
||||
protected readonly defaultTextColor: string;
|
||||
|
||||
constructor(protected field: ViewFieldRec, opts: Options = {}) {
|
||||
super();
|
||||
const {defaultTextColor = '#000000'} = opts;
|
||||
this.defaultTextColor = defaultTextColor;
|
||||
this.options = field.widgetOptionsJson;
|
||||
this.textColor = Computed.create(this, (use) => (
|
||||
use(this.field.textColor) || defaultTextColor
|
||||
)).onWrite((val) => this.field.textColor(val === defaultTextColor ? undefined : val));
|
||||
this.fillColor = fromKo(this.field.fillColor);
|
||||
|
||||
this.valueFormatter = fromKo(field.formatter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the DOM showing configuration buttons and fields in the sidebar.
|
||||
*/
|
||||
public buildConfigDom(): DomContents { return null; }
|
||||
public buildConfigDom(): DomContents {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the transform prompt config DOM in the few cases where it is necessary.
|
||||
* Child classes need not override this function if they do not require transform config options.
|
||||
*/
|
||||
public buildTransformConfigDom(): DomContents { return null; }
|
||||
public buildTransformConfigDom(): DomContents {
|
||||
return null;
|
||||
}
|
||||
|
||||
public buildColorConfigDom(): Element[] {
|
||||
return [
|
||||
cssLabel('CELL COLOR'),
|
||||
cssRow(
|
||||
colorSelect(
|
||||
this.textColor,
|
||||
this.fillColor,
|
||||
// Calling `field.widgetOptionsJson.save()` saves both fill and text color settings.
|
||||
() => this.field.widgetOptionsJson.save()
|
||||
)
|
||||
)
|
||||
];
|
||||
public buildColorConfigDom(gristDoc: GristDoc): DomContents {
|
||||
return dom.create(CellStyle, this.field, gristDoc, this.defaultTextColor);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,5 +84,7 @@ export abstract class NewAbstractWidget extends Disposable {
|
||||
/**
|
||||
* Returns the docComm object for communicating with the server.
|
||||
*/
|
||||
protected _getDocComm(): DocComm { return this._getDocData().docComm; }
|
||||
protected _getDocComm(): DocComm {
|
||||
return this._getDocData().docComm;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user