(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:
Jarosław Sadziński
2022-03-22 14:41:11 +01:00
parent 96a34122a5
commit b1c3943bf4
25 changed files with 952 additions and 231 deletions

View File

@@ -21,7 +21,7 @@ import {icon} from 'app/client/ui2018/icons';
import {IOptionFull, linkSelect, menu, menuDivider, menuItem, multiSelect} from 'app/client/ui2018/menus';
import {cssModalButtons, cssModalTitle} from 'app/client/ui2018/modals';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {openFormulaEditor} from 'app/client/widgets/FieldEditor';
import {openFormulaEditor} from 'app/client/widgets/FormulaEditor';
import {DataSourceTransformed, DestId, ImportResult, ImportTableResult, MergeOptions,
MergeOptionsMap, MergeStrategy, NEW_TABLE, SKIP_TABLE,
TransformColumn, TransformRule, TransformRuleMap} from 'app/common/ActiveDocAPI';

View File

@@ -75,7 +75,7 @@
color: var(--grist-actual-cell-color, black);
}
.field_clip.invalid {
.field_clip.invalid, .field_clip.field-error-from-style {
background-color: #ffb6c1;
color: black;
}

View File

@@ -303,6 +303,7 @@ declare module "app/client/models/DataTableModel" {
declare module "app/client/lib/koUtil" {
export interface ComputedWithKoUtils<T> extends ko.Computed<T> {
onlyNotifyUnequal(): this;
previousOnUndefined(): this;
}
export interface ObservableWithKoUtils<T> extends ko.Observable<T> {
assign(value: unknown): this;

View File

@@ -63,6 +63,14 @@ ko.subscribable.fn.onlyNotifyUnequal = function() {
return this;
};
/**
* Notifies only about distinct defined values. If the first value is undefined it will still be
* returned.
*/
ko.subscribable.fn.previousOnUndefined = function() {
this.equalityComparer = function(a, b) { return a === b || b === undefined; };
return this;
};
let _handlerFunc = (err) => {};
let _origKoComputed = ko.computed;
@@ -73,8 +81,8 @@ let _origKoComputed = ko.computed;
* evaluates successfully to its previous value (or _handlerFunc may rethrow the error).
*/
function _wrapComputedRead(readFunc) {
let lastValue;
return function() {
let lastValue;
try {
return (lastValue = readFunc.call(this));
} catch (err) {

View File

@@ -36,6 +36,8 @@ import {createValidationRec, ValidationRec} from 'app/client/models/entities/Val
import {createViewFieldRec, ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {createViewRec, ViewRec} from 'app/client/models/entities/ViewRec';
import {createViewSectionRec, ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
import {GristObjCode} from 'app/plugin/GristData';
import {decodeObject} from 'app/plugin/objtypes';
// Re-export all the entity types available. The recommended usage is like this:
// import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
@@ -95,6 +97,24 @@ export function refRecord<TRow extends MetaRowModel>(
return ko.pureComputed(() => tableModel.getRowModel(rowIdObs() || 0, true));
}
type RefListValue = [GristObjCode.List, ...number[]]|null;
/**
* Returns an observable with a list of records from another table, selected using RefList column.
* @param {TableModel} tableModel: The model for the table to return a record from.
* @param {ko.observable} rowsIdObs: An observable with a RefList value.
*/
export function refListRecords<TRow extends MetaRowModel>(
tableModel: MetaTableModel<TRow>, rowsIdObs: ko.Observable<RefListValue>|ko.Computed<RefListValue>
) {
return ko.pureComputed(() => {
const ids = decodeObject(rowsIdObs()) as number[]|null;
if (!Array.isArray(ids)) {
return [];
}
return ids.map(id => tableModel.getRowModel(id, true));
});
}
// Use an alias for brevity.
type MTM<RowModel extends MetaRowModel> = MetaTableModel<RowModel>;

View File

@@ -0,0 +1,19 @@
export interface Style {
textColor?: string;
fillColor?: string;
}
export class CombinedStyle implements Style {
public readonly textColor?: string;
public readonly fillColor?: string;
constructor(rules: Style[], flags: any[]) {
for (let i = 0; i < rules.length; i++) {
if (flags[i]) {
const textColor = rules[i].textColor;
const fillColor = rules[i].fillColor;
this.textColor = textColor || this.textColor;
this.fillColor = fillColor || this.fillColor;
}
}
}
}

View File

@@ -1,6 +1,7 @@
import {ColumnRec, DocModel, IRowModel, refRecord, ViewSectionRec} from 'app/client/models/DocModel';
import {ColumnRec, DocModel, IRowModel, refListRecords, refRecord, ViewSectionRec} from 'app/client/models/DocModel';
import {formatterForRec} from 'app/client/models/entities/ColumnRec';
import * as modelUtil from 'app/client/models/modelUtil';
import {Style} from 'app/client/models/Styles';
import * as UserType from 'app/client/widgets/UserType';
import {DocumentSettings} from 'app/common/DocumentSettings';
import {BaseFormatter} from 'app/common/ValueFormatter';
@@ -68,6 +69,9 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
textColor: modelUtil.KoSaveableObservable<string|undefined>;
fillColor: modelUtil.KoSaveableObservable<string>;
computedColor: ko.Computed<string|undefined>;
computedFill: ko.Computed<string>;
documentSettings: ko.PureComputed<DocumentSettings>;
// Helper for Reference/ReferenceList columns, which returns a formatter according
@@ -81,6 +85,26 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
// `formatter` formats actual cell values, e.g. a whole list from the display column.
formatter: ko.Computed<BaseFormatter>;
// Field can have a list of conditional styling rules. Each style is a combination of a formula and options
// that must by applied to a field. Style is persisted as a new hidden formula column and the list of such
// columns is stored as Reference List property ('rules') in a field or column.
// Rule for conditional style is a formula of the hidden column, style options are saved as JSON object in
// a styleOptions field (in that hidden formula column).
// If this field (or column) has a list of conditional styling rules.
hasRules: ko.Computed<boolean>;
// List of columns that are used as rules for conditional styles.
rulesCols: ko.Computed<ColumnRec[]>;
// List of columns ids that are used as rules for conditional styles.
rulesColsIds: ko.Computed<string[]>;
// List of styles used by conditional rules.
rulesStyles: modelUtil.KoSaveableObservable<Style[]>;
// Adds empty conditional style rule. Sets before sending to the server.
addEmptyRule(): Promise<void>;
// Removes one rule from the collection. Removes before sending update to the server.
removeRule(index: number): Promise<void>;
createValueParser(): (value: string) => any;
// Helper which adds/removes/updates field's displayCol to match the formula.
@@ -211,7 +235,7 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
// GridView, to avoid interfering with zebra stripes.
this.fillColor = modelUtil.savingComputed({
read: () => fillColorProp(),
write: (setter, val) => setter(fillColorProp, val.toUpperCase() === '#FFFFFF' ? '' : val),
write: (setter, val) => setter(fillColorProp, val?.toUpperCase() === '#FFFFFF' ? '' : (val ?? '')),
});
this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson());
@@ -231,4 +255,47 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
};
return docModel.docData.bundleActions("Update choices configuration", callback, actionOptions);
};
this.rulesCols = refListRecords(docModel.columns, ko.pureComputed(() => this._fieldOrColumn().rules()));
this.rulesColsIds = ko.pureComputed(() => this.rulesCols().map(c => c.colId()));
this.rulesStyles = modelUtil.fieldWithDefault(
this.widgetOptionsJson.prop("rulesOptions") as modelUtil.KoSaveableObservable<Style[]>,
[]);
this.hasRules = ko.pureComputed(() => this.rulesCols().length > 0);
// Helper method to add an empty rule (either initial or additional one).
// Style options are added to widget options directly and can be briefly out of sync,
// which is taken into account during rendering.
this.addEmptyRule = async () => {
const useCol = this.useColOptions.peek();
const action = [
'AddEmptyRule',
this.column.peek().table.peek().tableId.peek(),
useCol ? 0 : this.id.peek(), // field_ref
useCol ? this.column.peek().id.peek() : 0, // col_ref
];
await docModel.docData.sendAction(action, `Update rules for ${this.colId.peek()}`);
};
// Helper method to remove a rule.
this.removeRule = async (index: number) => {
const col = this.rulesCols.peek()[index];
if (!col) {
throw new Error(`There is no rule at index ${index}`);
}
const tableData = docModel.dataTables[col.table.peek().tableId.peek()].tableData;
const newStyles = this.rulesStyles.peek().slice();
if (newStyles.length >= index) {
newStyles.splice(index, 1);
} else {
console.debug(`There are not style options at index ${index}`);
}
const callback = () =>
Promise.all([
this.rulesStyles.setAndSave(newStyles),
tableData.sendTableAction(['RemoveColumn', col.colId.peek()])
]);
const actionOptions = {nestInActiveBundle: this.column.peek().isTransforming.peek()};
await docModel.docData.bundleActions("Remove conditional rule", callback, actionOptions);
};
}

View File

@@ -8,13 +8,13 @@ import {textButton} from 'app/client/ui2018/buttons';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {textInput} from 'app/client/ui2018/editableLabel';
import {cssIconButton, icon} from 'app/client/ui2018/icons';
import {IconName} from 'app/client/ui2018/IconList';
import {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus';
import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor';
import {sanitizeIdent} from 'app/common/gutil';
import {bundleChanges, Computed, dom, DomContents, DomElementArg, fromKo, MultiHolder,
Observable, styled, subscribe} from 'grainjs';
Observable, styled} from 'grainjs';
import * as ko from 'knockout';
import debounce = require('lodash/debounce');
import {IconName} from 'app/client/ui2018/IconList';
export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, cursor: ko.Computed<CursorPos>) {
const untieColId = origColumn.untieColIdFromLabel;
@@ -327,54 +327,7 @@ function buildFormula(
);
}
/**
* 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.
*/
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 cssFieldFormula = styled(buildHighlightedCode, `
export const cssFieldFormula = styled(buildHighlightedCode, `
flex: auto;
cursor: pointer;
margin-top: 4px;
@@ -429,7 +382,3 @@ const cssColTieConnectors = styled('div', `
border-left: none;
z-index: -1;
`);
const cssError = styled('div', `
color: ${colors.error};
`);

View File

@@ -599,7 +599,7 @@ export const cssButtonRow = styled(cssRow, `
export const cssIcon = styled(icon, `
flex: 0 0 auto;
background-color: ${colors.slate};
--icon-color: ${colors.slate};
`);
const cssTopBarItem = styled('div', `

View File

@@ -13,14 +13,14 @@ import { defaultMenuOptions, IOpenController, setPopupToCreateDom } from "popwea
* native color picker. Pressing Escape reverts to the saved value. Caller is expected to handle
* logging of onSave() callback rejection. In case of rejection, values are reverted to their saved one.
*/
export function colorSelect(textColor: Observable<string>, fillColor: Observable<string>,
onSave: () => Promise<void>): Element {
export function colorSelect(textColor: Observable<string|undefined>, fillColor: Observable<string|undefined>,
onSave: () => Promise<void>, allowNone = false): Element {
const selectBtn = cssSelectBtn(
cssContent(
cssButtonIcon(
'T',
dom.style('color', textColor),
dom.style('background-color', (use) => use(fillColor).slice(0, 7)),
dom.style('color', use => use(textColor) || ''),
dom.style('background-color', (use) => use(fillColor)?.slice(0, 7) || ''),
cssLightBorder.cls(''),
testId('btn-icon'),
),
@@ -30,21 +30,21 @@ export function colorSelect(textColor: Observable<string>, fillColor: Observable
testId('color-select'),
);
const domCreator = (ctl: IOpenController) => buildColorPicker(ctl, textColor, fillColor, onSave);
const domCreator = (ctl: IOpenController) => buildColorPicker(ctl, textColor, fillColor, onSave, allowNone);
setPopupToCreateDom(selectBtn, domCreator, {...defaultMenuOptions, placement: 'bottom-end'});
return selectBtn;
}
export function colorButton(textColor: Observable<string>, fillColor: Observable<string>,
export function colorButton(textColor: Observable<string|undefined>, fillColor: Observable<string|undefined>,
onSave: () => Promise<void>): Element {
const iconBtn = cssIconBtn(
icon(
'Dropdown',
dom.style('background-color', textColor),
dom.style('background-color', use => use(textColor) || ''),
testId('color-button-dropdown')
),
dom.style('background-color', (use) => use(fillColor).slice(0, 7)),
dom.style('background-color', (use) => use(fillColor)?.slice(0, 7) || ''),
dom.on('click', (e) => { e.stopPropagation(); e.preventDefault(); }),
testId('color-button'),
);
@@ -55,8 +55,10 @@ export function colorButton(textColor: Observable<string>, fillColor: Observable
return iconBtn;
}
function buildColorPicker(ctl: IOpenController, textColor: Observable<string>, fillColor: Observable<string>,
onSave: () => Promise<void>): Element {
function buildColorPicker(ctl: IOpenController, textColor: Observable<string|undefined>,
fillColor: Observable<string|undefined>,
onSave: () => Promise<void>,
allowNone = false): Element {
const textColorModel = PickerModel.create(null, textColor);
const fillColorModel = PickerModel.create(null, fillColor);
@@ -83,8 +85,8 @@ function buildColorPicker(ctl: IOpenController, textColor: Observable<string>, f
const colorSquare = (...args: DomArg[]) => cssColorSquare(
...args,
dom.style('color', textColor),
dom.style('background-color', fillColor),
dom.style('color', use => use(textColor) || ''),
dom.style('background-color', use => use(fillColor) || ''),
cssLightBorder.cls(''),
);
@@ -92,13 +94,15 @@ function buildColorPicker(ctl: IOpenController, textColor: Observable<string>, f
dom.create(PickerComponent, fillColorModel, {
colorSquare: colorSquare(),
title: 'fill',
defaultMode: 'lighter'
defaultMode: 'lighter',
allowNone
}),
cssVSpacer(),
dom.create(PickerComponent, textColorModel, {
colorSquare: colorSquare('T'),
title: 'text',
defaultMode: 'darker'
defaultMode: 'darker',
allowNone
}),
// gives focus and binds keydown events
@@ -118,6 +122,7 @@ interface PickerComponentOptions {
colorSquare: Element;
title: string;
defaultMode: 'darker'|'lighter';
allowNone?: boolean;
}
// PickerModel is a helper model that helps keep track of the server value for an observable that
@@ -127,7 +132,7 @@ interface PickerComponentOptions {
class PickerModel extends Disposable {
private _serverValue = this.obs.get();
private _localChange: boolean = false;
constructor(public obs: Observable<string>) {
constructor(public obs: Observable<string|undefined>) {
super();
this.autoDispose(this.obs.addListener((val) => {
if (this._localChange) { return; }
@@ -136,7 +141,7 @@ class PickerModel extends Disposable {
}
// Set the value picked by the user
public setValue(val: string) {
public setValue(val: string|undefined) {
this._localChange = true;
this.obs.set(val);
this._localChange = false;
@@ -155,7 +160,7 @@ class PickerModel extends Disposable {
class PickerComponent extends Disposable {
private _color = Computed.create(this, this._model.obs, (use, val) => val.toUpperCase().slice(0, 7));
private _color = Computed.create(this, this._model.obs, (use, val) => (val || '').toUpperCase().slice(0, 7));
private _mode = Observable.create<'darker'|'lighter'>(this, this._guessMode());
constructor(private _model: PickerModel, private _options: PickerComponentOptions) {
@@ -172,13 +177,17 @@ class PickerComponent extends Disposable {
cssColorInput(
{type: 'color'},
dom.attr('value', this._color),
dom.on('input', (ev, elem) => this._setValue(elem.value)),
dom.on('input', (ev, elem) => this._setValue(elem.value || undefined)),
testId(`${title}-input`),
),
),
cssHexBox(
this._color,
async (val) => { if (isValidHex(val)) { this._model.setValue(val); } },
async (val) => {
if ((this._options.allowNone && !val) || isValidHex(val)) {
this._model.setValue(val);
}
},
testId(`${title}-hex`),
// select the hex value on click. Doing it using settimeout allows to avoid some
// sporadically losing the selection just after the click.
@@ -207,7 +216,15 @@ class PickerComponent extends Disposable {
cssLightBorder.cls('', (use) => use(this._mode) === 'lighter'),
cssColorSquare.cls('-selected', (use) => use(this._color) === color),
dom.style('outline-color', (use) => use(this._mode) === 'lighter' ? '' : color),
dom.on('click', () => this._setValue(color)),
dom.on('click', () => {
// Clicking same color twice - removes the selection.
if (this._model.obs.get() === color &&
this._options.allowNone) {
this._setValue(undefined);
} else {
this._setValue(color);
}
}),
testId(`color-${color}`),
)
))),
@@ -216,7 +233,7 @@ class PickerComponent extends Disposable {
];
}
private _setValue(val: string) {
private _setValue(val: string|undefined) {
this._model.setValue(val);
}

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

View File

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

View File

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

View File

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

View File

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