(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

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