(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
pull/176/head
Jarosław Sadziński 2 years ago
parent 96a34122a5
commit b1c3943bf4

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

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

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

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

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

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

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

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

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

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

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

@ -341,3 +341,9 @@ export function isRefListType(type: string) {
export function isFullReferencingType(type: string) {
return type.startsWith('Ref:') || isRefListType(type);
}
export function isValidRuleValue(value: CellValue|undefined) {
// We want to strictly test if a value is boolean, when the value is 0 or 1 it might
// indicate other number in the future.
return value === null || typeof value === 'boolean';
}

@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
// tslint:disable:object-literal-key-quotes
export const SCHEMA_VERSION = 26;
export const SCHEMA_VERSION = 27;
export const schema = {
@ -38,6 +38,7 @@ export const schema = {
summarySourceCol : "Ref:_grist_Tables_column",
displayCol : "Ref:_grist_Tables_column",
visibleCol : "Ref:_grist_Tables_column",
rules : "RefList:_grist_Tables_column",
recalcWhen : "Int",
recalcDeps : "RefList:_grist_Tables_column",
},
@ -125,6 +126,7 @@ export const schema = {
displayCol : "Ref:_grist_Tables_column",
visibleCol : "Ref:_grist_Tables_column",
filter : "Text",
rules : "RefList:_grist_Tables_column",
},
"_grist_Validations": {
@ -226,6 +228,7 @@ export interface SchemaTypes {
summarySourceCol: number;
displayCol: number;
visibleCol: number;
rules: [GristObjCode.List, ...number[]]|null;
recalcWhen: number;
recalcDeps: [GristObjCode.List, ...number[]]|null;
};
@ -313,6 +316,7 @@ export interface SchemaTypes {
displayCol: number;
visibleCol: number;
filter: string;
rules: [GristObjCode.List, ...number[]]|null;
};
"_grist_Validations": {

@ -6,9 +6,9 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',26,'UTC','{"locale": "en-US"}');
INSERT INTO _grist_DocInfo VALUES(1,'','','',27,'UTC','{"locale": "en-US"}');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_External_database" (id INTEGER PRIMARY KEY, "host" TEXT DEFAULT '', "port" INTEGER DEFAULT 0, "username" TEXT DEFAULT '', "dialect" TEXT DEFAULT '', "database" TEXT DEFAULT '', "storage" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_External_table" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "databaseRef" INTEGER DEFAULT 0, "tableName" TEXT DEFAULT '');
@ -18,7 +18,7 @@ CREATE TABLE IF NOT EXISTS "_grist_TabBar" (id INTEGER PRIMARY KEY, "viewRef" IN
CREATE TABLE IF NOT EXISTS "_grist_Pages" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "indentation" INTEGER DEFAULT 0, "pagePos" NUMERIC DEFAULT 1e999);
CREATE TABLE IF NOT EXISTS "_grist_Views" (id INTEGER PRIMARY KEY, "name" TEXT DEFAULT '', "type" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colRef" INTEGER DEFAULT 0, "width" INTEGER DEFAULT 0, "widgetOptions" TEXT DEFAULT '', "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colRef" INTEGER DEFAULT 0, "width" INTEGER DEFAULT 0, "widgetOptions" TEXT DEFAULT '', "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeUploaded" DATETIME DEFAULT NULL);
@ -41,14 +41,14 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',26,'UTC','{"locale": "en-US"}');
INSERT INTO _grist_DocInfo VALUES(1,'','','',27,'UTC','{"locale": "en-US"}');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
INSERT INTO _grist_Tables_column VALUES(1,1,1,'manualSort','ManualSortPos','',0,'','manualSort',0,0,0,0,0,NULL);
INSERT INTO _grist_Tables_column VALUES(2,1,2,'A','Any','',1,'','A',0,0,0,0,0,NULL);
INSERT INTO _grist_Tables_column VALUES(3,1,3,'B','Any','',1,'','B',0,0,0,0,0,NULL);
INSERT INTO _grist_Tables_column VALUES(4,1,4,'C','Any','',1,'','C',0,0,0,0,0,NULL);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
INSERT INTO _grist_Tables_column VALUES(1,1,1,'manualSort','ManualSortPos','',0,'','manualSort',0,0,0,0,NULL,0,NULL);
INSERT INTO _grist_Tables_column VALUES(2,1,2,'A','Any','',1,'','A',0,0,0,0,NULL,0,NULL);
INSERT INTO _grist_Tables_column VALUES(3,1,3,'B','Any','',1,'','B',0,0,0,0,NULL,0,NULL);
INSERT INTO _grist_Tables_column VALUES(4,1,4,'C','Any','',1,'','C',0,0,0,0,NULL,0,NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_External_database" (id INTEGER PRIMARY KEY, "host" TEXT DEFAULT '', "port" INTEGER DEFAULT 0, "username" TEXT DEFAULT '', "dialect" TEXT DEFAULT '', "database" TEXT DEFAULT '', "storage" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_External_table" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "databaseRef" INTEGER DEFAULT 0, "tableName" TEXT DEFAULT '');
@ -63,13 +63,13 @@ INSERT INTO _grist_Views VALUES(1,'Table1','raw_data','');
CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '');
INSERT INTO _grist_Views_section VALUES(1,1,1,'record','',100,1,'','','','','','[]',0,0,0,'');
INSERT INTO _grist_Views_section VALUES(2,1,0,'record','',100,1,'','','','','','',0,0,0,'');
CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colRef" INTEGER DEFAULT 0, "width" INTEGER DEFAULT 0, "widgetOptions" TEXT DEFAULT '', "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '');
INSERT INTO _grist_Views_section_field VALUES(1,1,1,2,0,'',0,0,'');
INSERT INTO _grist_Views_section_field VALUES(2,1,2,3,0,'',0,0,'');
INSERT INTO _grist_Views_section_field VALUES(3,1,3,4,0,'',0,0,'');
INSERT INTO _grist_Views_section_field VALUES(4,2,4,2,0,'',0,0,'');
INSERT INTO _grist_Views_section_field VALUES(5,2,5,3,0,'',0,0,'');
INSERT INTO _grist_Views_section_field VALUES(6,2,6,4,0,'',0,0,'');
CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colRef" INTEGER DEFAULT 0, "width" INTEGER DEFAULT 0, "widgetOptions" TEXT DEFAULT '', "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL);
INSERT INTO _grist_Views_section_field VALUES(1,1,1,2,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(2,1,2,3,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(3,1,3,4,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(4,2,4,2,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(5,2,5,3,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(6,2,6,4,0,'',0,0,'',NULL);
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeUploaded" DATETIME DEFAULT NULL);

@ -12,6 +12,7 @@ import six
import records
import usertypes
import relabeling
import lookup
import table
import moment
from schema import RecalcWhen
@ -26,6 +27,14 @@ def _record_set(table_id, group_by, sort_by=None):
return func
def _record_ref_list_set(table_id, group_by, sort_by=None):
@usertypes.formulaType(usertypes.ReferenceList(table_id))
def func(rec, table):
lookup_table = table.docmodel.get_table(table_id)
return lookup_table.lookupRecords(sort_by=sort_by, **{group_by: lookup._Contains(rec.id)})
return func
def _record_inverse(table_id, ref_col):
@usertypes.formulaType(usertypes.Reference(table_id))
def func(rec, table):
@ -73,6 +82,8 @@ class MetaTableExtras(object):
summaryGroupByColumns = _record_set('_grist_Tables_column', 'summarySourceCol')
usedByCols = _record_set('_grist_Tables_column', 'displayCol')
usedByFields = _record_set('_grist_Views_section_field', 'displayCol')
ruleUsedByCols = _record_ref_list_set('_grist_Tables_column', 'rules')
ruleUsedByFields = _record_ref_list_set('_grist_Views_section_field', 'rules')
def tableId(rec, table):
return rec.parentId.tableId
@ -83,6 +94,12 @@ class MetaTableExtras(object):
"""
return len(rec.usedByCols) + len(rec.usedByFields)
def numRuleColUsers(rec, table):
"""
Returns the number of cols and fields using this col as a rule col
"""
return len(rec.ruleUsedByCols) + len(rec.ruleUsedByFields)
def recalcOnChangesToSelf(rec, table):
"""
Whether the column is a trigger-formula column that depends on itself, used for
@ -91,9 +108,10 @@ class MetaTableExtras(object):
return rec.recalcWhen == RecalcWhen.DEFAULT and rec.id in rec.recalcDeps
def setAutoRemove(rec, table):
"""Marks the col for removal if it's a display helper col with no more users."""
table.docmodel.setAutoRemove(rec,
rec.colId.startswith('gristHelper_Display') and rec.numDisplayColUsers == 0)
"""Marks the col for removal if it's a display/rule helper col with no more users."""
as_display = rec.colId.startswith('gristHelper_Display') and rec.numDisplayColUsers == 0
as_rule = rec.colId.startswith('gristHelper_Conditional') and rec.numRuleColUsers == 0
table.docmodel.setAutoRemove(rec, as_display or as_rule)
class _grist_Views(object):

@ -898,3 +898,11 @@ def migration26(tdset):
new_view_section_id += 1
return tdset.apply_doc_actions(doc_actions)
@migration(schema_version=27)
def migration27(tdset):
return tdset.apply_doc_actions([
add_column('_grist_Tables_column', 'rules', 'RefList:_grist_Tables_column'),
add_column('_grist_Views_section_field', 'rules', 'RefList:_grist_Tables_column'),
])

@ -15,7 +15,7 @@ import six
import actions
SCHEMA_VERSION = 26
SCHEMA_VERSION = 27
def make_column(col_id, col_type, formula='', isFormula=False):
return {
@ -83,6 +83,8 @@ def schema_create_actions():
# E.g. Foo.person may have a visibleCol pointing to People.Name, with the displayCol
# pointing to Foo._gristHelper_DisplayX column with the formula "$person.Name".
make_column("visibleCol", "Ref:_grist_Tables_column"),
# Points to formula columns that hold conditional formatting rules.
make_column("rules", "RefList:_grist_Tables_column"),
# Instructions when to recalculate the formula on a column with isFormula=False (previously
# known as a "default formula"). Values are RecalcWhen constants defined below.
@ -206,6 +208,8 @@ def schema_create_actions():
make_column("visibleCol", "Ref:_grist_Tables_column"),
# DEPRECATED: replaced with _grist_Filters in version 25. Do not remove or reuse.
make_column("filter", "Text"),
# Points to formula columns that hold conditional formatting rules for this field.
make_column("rules", "RefList:_grist_Tables_column"),
]),
# The code for all of the validation rules available to a Grist document

@ -30,6 +30,17 @@ def _get_colinfo_dict(col_info, with_id=False):
return col_values
def _copy_widget_options(options):
"""Copies widgetOptions for a summary group-by column (omitting conditional formatting rules)"""
if not options:
return options
try:
options = json.loads(options)
except ValueError:
# widgetOptions are not always a valid json value (especially in tests)
return options
return json.dumps({k: v for k, v in options.items() if k != "rulesOptions"})
# To generate code, we need to know for each summary table, what its source table is. It would be
# easy if we had access to metadata records, but (at least for now) we generate all code based on
# schema only. So we encode the source table name inside of the summary table name.
@ -130,6 +141,7 @@ class SummaryActions(object):
_get_colinfo_dict(ci, with_id=False))
yield self.docmodel.columns.table.get_record(result['colRef'])
def _get_or_create_summary(self, source_table, source_groupby_columns, formula_colinfo):
"""
Finds a summary table or creates a new one, based on source_table, grouped by the columns
@ -144,6 +156,7 @@ class SummaryActions(object):
col=c,
isFormula=False,
formula='',
widgetOptions=_copy_widget_options(c.widgetOptions),
type=summary_groupby_col_type(c.type)
)
for c in source_groupby_columns

@ -0,0 +1,192 @@
# -*- coding: utf-8 -*-
import testutil
import test_engine
class TestRules(test_engine.EngineTestCase):
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Inventory", [
[2, "Label", "Text", False, "", "", ""],
[3, "Stock", "Int", False, "", "", ""],
]],
],
"DATA": {
"Inventory": [
["id", "Label", "Stock"],
[1, "A1", 0],
[2, "A2", 2],
[3, "A3", 5],
# Duplicate
[4, "A1", 10]
],
}
})
# Helper for rules action
def add_empty(self, col_id):
return self.apply_user_action(['AddEmptyRule', "Inventory", 0, col_id])
def field_add_empty(self, field_id):
return self.apply_user_action(['AddEmptyRule', "Inventory", field_id, 0])
def set_rule(self, col_id, rule_index, formula):
rules = self.engine.docmodel.columns.table.get_record(col_id).rules
rule = list(rules)[rule_index]
return self.apply_user_action(['UpdateRecord', '_grist_Tables_column',
rule.id, {"formula": formula}])
def field_set_rule(self, field_id, rule_index, formula):
rules = self.engine.docmodel.view_fields.table.get_record(field_id).rules
rule = list(rules)[rule_index]
return self.apply_user_action(['UpdateRecord', '_grist_Tables_column',
rule.id, {"formula": formula}])
def remove_rule(self, col_id, rule_index):
rules = self.engine.docmodel.columns.table.get_record(col_id).rules
rule = list(rules)[rule_index]
return self.apply_user_action(['RemoveColumn', 'Inventory', rule.colId])
def field_remove_rule(self, field_id, rule_index):
rules = self.engine.docmodel.view_fields.table.get_record(field_id).rules
rule = list(rules)[rule_index]
return self.apply_user_action(['RemoveColumn', 'Inventory', rule.colId])
def test_simple_rules(self):
self.load_sample(self.sample)
# Mark all records with Stock = 0
out_actions = self.add_empty(3)
self.assertPartialOutActions(out_actions, {"stored": [
["AddColumn", "Inventory", "gristHelper_ConditionalRule",
{"formula": "", "isFormula": True, "type": "Any"}],
["AddRecord", "_grist_Tables_column", 4,
{"colId": "gristHelper_ConditionalRule", "formula": "", "isFormula": True,
"label": "gristHelper_ConditionalRule", "parentId": 1, "parentPos": 3.0,
"type": "Any",
"widgetOptions": ""}],
["UpdateRecord", "_grist_Tables_column", 3, {"rules": ["L", 4]}],
]})
out_actions = self.set_rule(3, 0, "$Stock == 0")
self.assertPartialOutActions(out_actions, {"stored": [
["ModifyColumn", "Inventory", "gristHelper_ConditionalRule",
{"formula": "$Stock == 0"}],
["UpdateRecord", "_grist_Tables_column", 4, {"formula": "$Stock == 0"}],
["BulkUpdateRecord", "Inventory", [1, 2, 3, 4],
{"gristHelper_ConditionalRule": [True, False, False, False]}],
]})
# Replace this rule with another rule to mark Stock = 2
out_actions = self.set_rule(3, 0, "$Stock == 2")
self.assertPartialOutActions(out_actions, {"stored": [
["ModifyColumn", "Inventory", "gristHelper_ConditionalRule",
{"formula": "$Stock == 2"}],
["UpdateRecord", "_grist_Tables_column", 4, {"formula": "$Stock == 2"}],
["BulkUpdateRecord", "Inventory", [1, 2],
{"gristHelper_ConditionalRule": [False, True]}],
]})
# Add another rule Stock = 10
out_actions = self.add_empty(3)
self.assertPartialOutActions(out_actions, {"stored": [
["AddColumn", "Inventory", "gristHelper_ConditionalRule2",
{"formula": "", "isFormula": True, "type": "Any"}],
["AddRecord", "_grist_Tables_column", 5,
{"colId": "gristHelper_ConditionalRule2", "formula": "", "isFormula": True,
"label": "gristHelper_ConditionalRule2", "parentId": 1, "parentPos": 4.0,
"type": "Any",
"widgetOptions": ""}],
["UpdateRecord", "_grist_Tables_column", 3, {"rules": ["L", 4, 5]}],
]})
out_actions = self.set_rule(3, 1, "$Stock == 10")
self.assertPartialOutActions(out_actions, {"stored": [
["ModifyColumn", "Inventory", "gristHelper_ConditionalRule2",
{"formula": "$Stock == 10"}],
["UpdateRecord", "_grist_Tables_column", 5, {"formula": "$Stock == 10"}],
["BulkUpdateRecord", "Inventory", [1, 2, 3, 4],
{"gristHelper_ConditionalRule2": [False, False, False, True]}],
]})
# Remove the last rule
out_actions = self.remove_rule(3, 1)
self.assertPartialOutActions(out_actions, {"stored": [
["RemoveRecord", "_grist_Tables_column", 5],
["UpdateRecord", "_grist_Tables_column", 3, {"rules": ["L", 4]}],
["RemoveColumn", "Inventory", "gristHelper_ConditionalRule2"]
]})
# Remove last rule
out_actions = self.remove_rule(3, 0)
self.assertPartialOutActions(out_actions, {"stored": [
["RemoveRecord", "_grist_Tables_column", 4],
["UpdateRecord", "_grist_Tables_column", 3, {"rules": None}],
["RemoveColumn", "Inventory", "gristHelper_ConditionalRule"]
]})
def test_duplicates(self):
self.load_sample(self.sample)
# Create rule that marks duplicate values
formula = "len(Inventory.lookupRecords(Label=$Label)) > 1"
# First add rule on stock column, to test naming - second rule column should have 2 as a suffix
self.add_empty(3)
self.set_rule(3, 0, "$Stock == 0")
# Now highlight duplicates on labels
self.add_empty(2)
out_actions = self.set_rule(2, 0, formula)
self.assertPartialOutActions(out_actions, {"stored": [
["ModifyColumn", "Inventory", "gristHelper_ConditionalRule2",
{"formula": "len(Inventory.lookupRecords(Label=$Label)) > 1"}],
["UpdateRecord", "_grist_Tables_column", 5,
{"formula": "len(Inventory.lookupRecords(Label=$Label)) > 1"}],
["BulkUpdateRecord", "Inventory", [1, 2, 3, 4],
{"gristHelper_ConditionalRule2": [True, False, False, True]}]
]})
def test_column_removal(self):
# Test that rules are removed with a column.
self.load_sample(self.sample)
self.add_empty(3)
self.set_rule(3, 0, "$Stock == 0")
before = self.engine.docmodel.columns.lookupOne(colId='gristHelper_ConditionalRule')
self.assertNotEqual(before, 0)
out_actions = self.apply_user_action(['RemoveColumn', 'Inventory', 'Stock'])
self.assertPartialOutActions(out_actions, {"stored": [
["BulkRemoveRecord", "_grist_Tables_column", [3, 4]],
["RemoveColumn", "Inventory", "Stock"],
["RemoveColumn", "Inventory", "gristHelper_ConditionalRule"],
]})
def test_column_removal_for_a_field(self):
# Test that rules are removed with a column when attached to a field.
self.load_sample(self.sample)
self.apply_user_action(['CreateViewSection', 1, 0, 'record', None])
self.field_add_empty(2)
self.field_set_rule(2, 0, "$Stock == 0")
before = self.engine.docmodel.columns.lookupOne(colId='gristHelper_ConditionalRule')
self.assertNotEqual(before, 0)
out_actions = self.apply_user_action(['RemoveColumn', 'Inventory', 'Stock'])
self.assertPartialOutActions(out_actions, {"stored": [
["RemoveRecord", "_grist_Views_section_field", 2],
["BulkRemoveRecord", "_grist_Tables_column", [3, 4]],
["RemoveColumn", "Inventory", "Stock"],
["RemoveColumn", "Inventory", "gristHelper_ConditionalRule"],
]})
def test_field_removal(self):
# Test that rules are removed with a field.
self.load_sample(self.sample)
self.apply_user_action(['CreateViewSection', 1, 0, 'record', None])
self.field_add_empty(2)
self.field_set_rule(2, 0, "$Stock == 0")
rule_id = self.engine.docmodel.columns.lookupOne(colId='gristHelper_ConditionalRule').id
self.assertNotEqual(rule_id, 0)
out_actions = self.apply_user_action(['RemoveRecord', '_grist_Views_section_field', 2])
self.assertPartialOutActions(out_actions, {"stored": [
["RemoveRecord", "_grist_Views_section_field", 2],
["RemoveRecord", "_grist_Tables_column", rule_id],
["RemoveColumn", "Inventory", "gristHelper_ConditionalRule"]
]})

@ -183,7 +183,8 @@ def allowed_summary_change(key, updated, original):
"""
Checks if summary group by column can be modified.
"""
if updated == original:
# Conditional styles are allowed
if updated == original or key == 'rules':
return True
elif key == 'widgetOptions':
try:
@ -196,7 +197,8 @@ def allowed_summary_change(key, updated, original):
# TODO: move choice items to separate column
allowed_to_change = {'widget', 'dateFormat', 'timeFormat', 'isCustomDateFormat', 'alignment',
'fillColor', 'textColor', 'isCustomTimeFormat', 'isCustomDateFormat',
'numMode', 'numSign', 'decimals', 'maxDecimals', 'currency'}
'numMode', 'numSign', 'decimals', 'maxDecimals', 'currency',
'rulesOptions'}
# Helper function to remove protected keys from dictionary.
def trim(options):
return {k: v for k, v in options.items() if k not in allowed_to_change}
@ -1040,21 +1042,33 @@ class UserActions(object):
re_sort_specs.append(json.dumps(updated_sort))
self._docmodel.update(re_sort_sections, sortColRefs=re_sort_specs)
more_removals = set()
# Remove all rules columns genereted for view fields for all removed columns.
# Those columns would be auto-removed but we will remove them immediately to
# avoid any recalculations.
more_removals.update([rule for col in col_recs
for field in col.viewFields
for rule in field.rules])
# Remove all view fields for all removed columns.
# Bypass the check for raw data view sections.
field_ids = [f.id for c in col_recs for f in c.viewFields]
self.doBulkRemoveRecord("_grist_Views_section_field", field_ids)
# If there is a displayCol, it may get auto-removed, but may first produce calc actions
# triggered by the removal of this column. To avoid those, remove displayCols immediately.
# Also remove displayCol for any columns or fields that use this col as their visibleCol.
more_removals = set()
more_removals.update([c.displayCol for c in col_recs],
[vc.displayCol for c in col_recs
for vc in self._docmodel.columns.lookupRecords(visibleCol=c.id)],
[vf.displayCol for c in col_recs
for vf in self._docmodel.view_fields.lookupRecords(visibleCol=c.id)])
# Remove also all autogenereted formula columns for conditional styles.
more_removals.update([rule for col in col_recs
for rule in col.rules])
# Add any extra removals after removing the requested columns in the requested order.
orig_removals = set(col_recs)
all_removals = col_recs + sorted(c for c in more_removals if c.id and c not in orig_removals)
@ -1498,6 +1512,32 @@ class UserActions(object):
if row_ids:
self.BulkUpdateRecord('_grist_Filters', row_ids, {"filter": values})
@useraction
def AddEmptyRule(self, table_id, field_ref, col_ref):
"""
Adds empty conditional style rule to a field or column.
"""
assert table_id, "table_id is required"
assert field_ref or col_ref, "field_ref or col_ref is required"
assert not field_ref or not col_ref, "can't set both field_ref and col_ref"
if field_ref:
field_or_col = self._docmodel.view_fields.table.get_record(field_ref)
else:
field_or_col = self._docmodel.columns.table.get_record(col_ref)
col_info = self.AddHiddenColumn(table_id, 'gristHelper_ConditionalRule', {
"type": "Any",
"isFormula": True,
"formula": ''
})
new_rule = col_info['colRef']
existing_rules = field_or_col.rules._get_encodable_row_ids() if field_or_col.rules else []
updated_rules = existing_rules + [new_rule]
self._docmodel.update([field_or_col], rules=[encode_object(updated_rules)])
#----------------------------------------
# User actions on tables.
#----------------------------------------

Binary file not shown.
Loading…
Cancel
Save