diff --git a/app/client/components/Importer.ts b/app/client/components/Importer.ts index 7cabed28..987a6f78 100644 --- a/app/client/components/Importer.ts +++ b/app/client/components/Importer.ts @@ -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'; diff --git a/app/client/components/viewCommon.css b/app/client/components/viewCommon.css index ba53da84..911eaa91 100644 --- a/app/client/components/viewCommon.css +++ b/app/client/components/viewCommon.css @@ -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; } diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index 80b5c656..5178d856 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -303,6 +303,7 @@ declare module "app/client/models/DataTableModel" { declare module "app/client/lib/koUtil" { export interface ComputedWithKoUtils extends ko.Computed { onlyNotifyUnequal(): this; + previousOnUndefined(): this; } export interface ObservableWithKoUtils extends ko.Observable { assign(value: unknown): this; diff --git a/app/client/lib/koUtil.js b/app/client/lib/koUtil.js index 2d401dcc..8c66c05e 100644 --- a/app/client/lib/koUtil.js +++ b/app/client/lib/koUtil.js @@ -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) { diff --git a/app/client/models/DocModel.ts b/app/client/models/DocModel.ts index dedb5315..fbe51a0c 100644 --- a/app/client/models/DocModel.ts +++ b/app/client/models/DocModel.ts @@ -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( 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( + tableModel: MetaTableModel, rowsIdObs: ko.Observable|ko.Computed +) { + 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 = MetaTableModel; diff --git a/app/client/models/Styles.ts b/app/client/models/Styles.ts new file mode 100644 index 00000000..348ebaf5 --- /dev/null +++ b/app/client/models/Styles.ts @@ -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; + } + } + } +} diff --git a/app/client/models/entities/ViewFieldRec.ts b/app/client/models/entities/ViewFieldRec.ts index 4652281b..ba2bbe12 100644 --- a/app/client/models/entities/ViewFieldRec.ts +++ b/app/client/models/entities/ViewFieldRec.ts @@ -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; fillColor: modelUtil.KoSaveableObservable; + computedColor: ko.Computed; + computedFill: ko.Computed; + documentSettings: ko.PureComputed; // 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; + // 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; + // List of columns that are used as rules for conditional styles. + rulesCols: ko.Computed; + // List of columns ids that are used as rules for conditional styles. + rulesColsIds: ko.Computed; + // List of styles used by conditional rules. + rulesStyles: modelUtil.KoSaveableObservable; + + // Adds empty conditional style rule. Sets before sending to the server. + addEmptyRule(): Promise; + // Removes one rule from the collection. Removes before sending update to the server. + removeRule(index: number): Promise; + 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, + []); + 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); + }; } diff --git a/app/client/ui/FieldConfig.ts b/app/client/ui/FieldConfig.ts index c965629d..e62eeb48 100644 --- a/app/client/ui/FieldConfig.ts +++ b/app/client/ui/FieldConfig.ts @@ -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) { 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}; -`); diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 732575a3..f1b0a689 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -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', ` diff --git a/app/client/ui2018/ColorSelect.ts b/app/client/ui2018/ColorSelect.ts index 2938448d..82e297d9 100644 --- a/app/client/ui2018/ColorSelect.ts +++ b/app/client/ui2018/ColorSelect.ts @@ -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, fillColor: Observable, - onSave: () => Promise): Element { +export function colorSelect(textColor: Observable, fillColor: Observable, + onSave: () => Promise, 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, 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, fillColor: Observable, +export function colorButton(textColor: Observable, fillColor: Observable, onSave: () => Promise): 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, fillColor: Observable return iconBtn; } -function buildColorPicker(ctl: IOpenController, textColor: Observable, fillColor: Observable, - onSave: () => Promise): Element { +function buildColorPicker(ctl: IOpenController, textColor: Observable, + fillColor: Observable, + onSave: () => Promise, + 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, 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, 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) { + constructor(public obs: Observable) { 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); } diff --git a/app/client/widgets/CellStyle.ts b/app/client/widgets/CellStyle.ts new file mode 100644 index 00000000..00dda466 --- /dev/null +++ b/app/client/widgets/CellStyle.ts @@ -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; + protected fillColor: Observable; + // Holds data from currently selected record (holds data only when this field has conditional styles). + protected currentRecord: Computed; + // 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, + column: ColumnRec, + hasError: Observable + ) { + 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; +`); diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index 8c751242..5ac34923 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -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; +`); diff --git a/app/client/widgets/FieldEditor.ts b/app/client/widgets/FieldEditor.ts index b84a27de..47f5f1fe 100644 --- a/app/client/widgets/FieldEditor.ts +++ b/app/client/widgets/FieldEditor.ts @@ -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, - 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, -}): 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|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; -} diff --git a/app/client/widgets/FormulaEditor.ts b/app/client/widgets/FormulaEditor.ts index d962d6f3..664245ee 100644 --- a/app/client/widgets/FormulaEditor.ts +++ b/app/client/widgets/FormulaEditor.ts @@ -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, } @@ -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, + 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, +}): 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|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}; +`); diff --git a/app/client/widgets/NewAbstractWidget.ts b/app/client/widgets/NewAbstractWidget.ts index 774db4ab..ff42472d 100644 --- a/app/client/widgets/NewAbstractWidget.ts +++ b/app/client/widgets/NewAbstractWidget.ts @@ -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; protected textColor: Observable; protected fillColor: Observable; + 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; + } } diff --git a/app/common/gristTypes.ts b/app/common/gristTypes.ts index 0b4ff9cf..977014e7 100644 --- a/app/common/gristTypes.ts +++ b/app/common/gristTypes.ts @@ -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'; +} diff --git a/app/common/schema.ts b/app/common/schema.ts index fcb05e7c..a3f5e111 100644 --- a/app/common/schema.ts +++ b/app/common/schema.ts @@ -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": { diff --git a/app/server/lib/initialDocSql.ts b/app/server/lib/initialDocSql.ts index d735ef01..484223bd 100644 --- a/app/server/lib/initialDocSql.ts +++ b/app/server/lib/initialDocSql.ts @@ -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); diff --git a/sandbox/grist/docmodel.py b/sandbox/grist/docmodel.py index 22b9952d..220ffbf9 100644 --- a/sandbox/grist/docmodel.py +++ b/sandbox/grist/docmodel.py @@ -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): diff --git a/sandbox/grist/migrations.py b/sandbox/grist/migrations.py index 33ea99f5..b0c5b61f 100644 --- a/sandbox/grist/migrations.py +++ b/sandbox/grist/migrations.py @@ -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'), + ]) diff --git a/sandbox/grist/schema.py b/sandbox/grist/schema.py index 2995a374..5aa08b00 100644 --- a/sandbox/grist/schema.py +++ b/sandbox/grist/schema.py @@ -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 diff --git a/sandbox/grist/summary.py b/sandbox/grist/summary.py index e84ceef9..38028de2 100644 --- a/sandbox/grist/summary.py +++ b/sandbox/grist/summary.py @@ -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 diff --git a/sandbox/grist/test_rules.py b/sandbox/grist/test_rules.py new file mode 100644 index 00000000..65a7e078 --- /dev/null +++ b/sandbox/grist/test_rules.py @@ -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"] + ]}) diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py index 9056dfcd..1d67c72d 100644 --- a/sandbox/grist/useractions.py +++ b/sandbox/grist/useractions.py @@ -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. #---------------------------------------- diff --git a/test/fixtures/docs/Hello.grist b/test/fixtures/docs/Hello.grist index 23dbb9a9..e8604346 100644 Binary files a/test/fixtures/docs/Hello.grist and b/test/fixtures/docs/Hello.grist differ