(core) Implementing row conditional formatting

Summary:
Conditional formatting can now be used for whole rows.
Related fix:
- Font styles weren't applicable for summary columns.
- Checkbox and slider weren't using colors properly

Test Plan: Existing and new tests

Reviewers: paulfitz, georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3547
This commit is contained in:
Jarosław Sadziński 2022-08-08 15:32:50 +02:00
parent fbba6b8f52
commit 9e4d802405
52 changed files with 823 additions and 439 deletions

View File

@ -26,7 +26,7 @@ function AceEditor(options) {
this.saveValueOnBlurEvent = !(options.saveValueOnBlurEvent === false); this.saveValueOnBlurEvent = !(options.saveValueOnBlurEvent === false);
this.calcSize = options.calcSize || ((_elem, size) => size); this.calcSize = options.calcSize || ((_elem, size) => size);
this.gristDoc = options.gristDoc || null; this.gristDoc = options.gristDoc || null;
this.field = options.field || null; this.column = options.column || null;
this.editorState = options.editorState || null; this.editorState = options.editorState || null;
this._readonly = options.readonly || false; this._readonly = options.readonly || false;
@ -185,10 +185,10 @@ AceEditor.prototype.setFontSize = function(pxVal) {
AceEditor.prototype._setup = function() { AceEditor.prototype._setup = function() {
// Standard editor setup // Standard editor setup
this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom)); this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom));
if (this.gristDoc && this.field) { if (this.gristDoc && this.column) {
const getSuggestions = (prefix) => { const getSuggestions = (prefix) => {
const tableId = this.gristDoc.viewModel.activeSection().table().tableId(); const tableId = this.gristDoc.viewModel.activeSection().table().tableId();
const columnId = this.field.column().colId(); const columnId = this.column.colId();
return this.gristDoc.docComm.autocomplete(prefix, tableId, columnId); return this.gristDoc.docComm.autocomplete(prefix, tableId, columnId);
}; };
setupAceEditorCompletions(this.editor, {getSuggestions}); setupAceEditorCompletions(this.editor, {getSuggestions});

View File

@ -12,7 +12,7 @@ import {reportError} from 'app/client/models/errors';
import {KoSaveableObservable, ObjObservable, setSaveValue} from 'app/client/models/modelUtil'; import {KoSaveableObservable, ObjObservable, setSaveValue} from 'app/client/models/modelUtil';
import {SortedRowSet} from 'app/client/models/rowset'; import {SortedRowSet} from 'app/client/models/rowset';
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanel'; import {cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
import {cssFieldEntry, cssFieldLabel, IField, VisibleFieldsConfig } from 'app/client/ui/VisibleFieldsConfig'; import {cssFieldEntry, cssFieldLabel, IField, VisibleFieldsConfig } from 'app/client/ui/VisibleFieldsConfig';
import {IconName} from 'app/client/ui2018/IconList'; import {IconName} from 'app/client/ui2018/IconList';
import {squareCheckbox} from 'app/client/ui2018/checkbox'; import {squareCheckbox} from 'app/client/ui2018/checkbox';

View File

@ -37,6 +37,7 @@
min-height: 16px; min-height: 16px;
white-space: pre; white-space: pre;
word-wrap: break-word; word-wrap: break-word;
color: black;
} }
.g_record_detail_value.record-add { .g_record_detail_value.record-add {

View File

@ -8,7 +8,7 @@
import * as AceEditor from 'app/client/components/AceEditor'; import * as AceEditor from 'app/client/components/AceEditor';
import {ColumnTransform} from 'app/client/components/ColumnTransform'; import {ColumnTransform} from 'app/client/components/ColumnTransform';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {cssButtonRow} from 'app/client/ui/RightPanel'; import {cssButtonRow} from 'app/client/ui/RightPanelStyles';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {testId} from 'app/client/ui2018/cssVars'; import {testId} from 'app/client/ui2018/cssVars';
import {FieldBuilder} from 'app/client/widgets/FieldBuilder'; import {FieldBuilder} from 'app/client/widgets/FieldBuilder';

View File

@ -1,26 +1,28 @@
/* globals alert, document, $ */ /* globals alert, document, $ */
var _ = require('underscore'); const _ = require('underscore');
var ko = require('knockout'); const ko = require('knockout');
const debounce = require('lodash/debounce'); const debounce = require('lodash/debounce');
var gutil = require('app/common/gutil'); const gutil = require('app/common/gutil');
var BinaryIndexedTree = require('app/common/BinaryIndexedTree'); const BinaryIndexedTree = require('app/common/BinaryIndexedTree');
const {Sort} = require('app/common/SortSpec'); const {Sort} = require('app/common/SortSpec');
var dom = require('../lib/dom'); const dom = require('../lib/dom');
var kd = require('../lib/koDom'); const kd = require('../lib/koDom');
var kf = require('../lib/koForm'); const kf = require('../lib/koForm');
var koDomScrolly = require('../lib/koDomScrolly'); const koDomScrolly = require('../lib/koDomScrolly');
var tableUtil = require('../lib/tableUtil'); const tableUtil = require('../lib/tableUtil');
var {addToSort, sortBy} = require('../lib/sortUtil'); const {addToSort, sortBy} = require('../lib/sortUtil');
var commands = require('./commands'); const commands = require('./commands');
var viewCommon = require('./viewCommon'); const viewCommon = require('./viewCommon');
var Base = require('./Base'); const Base = require('./Base');
var BaseView = require('./BaseView'); const BaseView = require('./BaseView');
var selector = require('./Selector'); const selector = require('./Selector');
var {CopySelection} = require('./CopySelection'); const {CopySelection} = require('./CopySelection');
const koUtil = require('app/client/lib/koUtil');
const convert = require('color-convert');
const {renderAllRows} = require('app/client/components/Printing'); const {renderAllRows} = require('app/client/components/Printing');
const {reportError} = require('app/client/models/AppModel'); const {reportError} = require('app/client/models/AppModel');
@ -40,6 +42,7 @@ const {contextMenu} = require('app/client/ui/contextMenu');
const {menuToggle} = require('app/client/ui/MenuToggle'); const {menuToggle} = require('app/client/ui/MenuToggle');
const {showTooltip} = require('app/client/ui/tooltips'); const {showTooltip} = require('app/client/ui/tooltips');
const {parsePasteForView} = require("./BaseView2"); const {parsePasteForView} = require("./BaseView2");
const {CombinedStyle} = require("app/client/models/Styles");
// A threshold for interpreting a motionless click as a click rather than a drag. // A threshold for interpreting a motionless click as a click rather than a drag.
@ -1111,8 +1114,41 @@ GridView.prototype.buildDom = function() {
// rows. IsCellActive is only subscribed to columns for the active row. This way, when // rows. IsCellActive is only subscribed to columns for the active row. This way, when
// the cursor moves, there are (rows+2*columns) calls rather than rows*columns. // the cursor moves, there are (rows+2*columns) calls rather than rows*columns.
var isRowActive = ko.computed(() => row._index() === self.cursor.rowIndex()); var isRowActive = ko.computed(() => row._index() === self.cursor.rowIndex());
const computedFlags = ko.pureComputed(() => {
return self.viewSection.rulesColsIds().map(colRef => {
if (row.cells[colRef]) { return row.cells[colRef]() || false; }
return false;
});
});
const computedRule = koUtil.withKoUtils(ko.pureComputed(() => {
if (row._isAddRow() || !row.id()) { return null; }
const flags = computedFlags();
if (flags.length === 0) { return null; }
const styles = self.viewSection.rulesStyles() || [];
return { style : new CombinedStyle(styles, flags) };
}, this).extend({deferred: true}));
const fillColor = buildStyleOption(self, computedRule, 'fillColor');
const zebraColor = ko.pureComputed(() => calcZebra(fillColor()));
const textColor = buildStyleOption(self, computedRule, 'textColor');
const fontBold = buildStyleOption(self, computedRule, 'fontBold');
const fontItalic = buildStyleOption(self, computedRule, 'fontItalic');
const fontUnderline = buildStyleOption(self, computedRule, 'fontUnderline');
const fontStrikethrough = buildStyleOption(self, computedRule, 'fontStrikethrough');
return dom('div.gridview_row', return dom('div.gridview_row',
dom.autoDispose(isRowActive), dom.autoDispose(isRowActive),
dom.autoDispose(computedFlags),
dom.autoDispose(computedRule),
dom.autoDispose(textColor),
dom.autoDispose(fillColor),
dom.autoDispose(zebraColor),
dom.autoDispose(fontBold),
dom.autoDispose(fontItalic),
dom.autoDispose(fontUnderline),
dom.autoDispose(fontStrikethrough),
// rowid dom // rowid dom
dom('div.gridview_data_row_num', dom('div.gridview_data_row_num',
@ -1159,6 +1195,13 @@ GridView.prototype.buildDom = function() {
kd.toggleClass('record-add', row._isAddRow), kd.toggleClass('record-add', row._isAddRow),
kd.style('borderLeftWidth', v.borderWidthPx), kd.style('borderLeftWidth', v.borderWidthPx),
kd.style('borderBottomWidth', v.borderWidthPx), kd.style('borderBottomWidth', v.borderWidthPx),
kd.toggleClass('font-bold', fontBold),
kd.toggleClass('font-underline', fontUnderline),
kd.toggleClass('font-italic', fontItalic),
kd.toggleClass('font-strikethrough', fontStrikethrough),
kd.style('--grist-row-background-color', fillColor),
kd.style('--grist-row-background-color-zebra', zebraColor),
kd.style('--grist-row-color', textColor),
//These are grabbed from v.optionsObj at start of GridView buildDom //These are grabbed from v.optionsObj at start of GridView buildDom
kd.toggleClass('record-hlines', vHorizontalGridlines), kd.toggleClass('record-hlines', vHorizontalGridlines),
kd.toggleClass('record-vlines', vVerticalGridlines), kd.toggleClass('record-vlines', vVerticalGridlines),
@ -1611,6 +1654,15 @@ GridView.prototype._duplicateRows = async function() {
} }
} }
function buildStyleOption(owner, computedRule, optionName) {
return ko.computed(() => {
if (owner.isDisposed()) { return null; }
const rule = computedRule();
if (!rule || !rule.style) { return ''; }
return rule.style[optionName] || '';
});
}
// Helper to show tooltip over column selection in the full edit mode. // Helper to show tooltip over column selection in the full edit mode.
class HoverColumnTooltip { class HoverColumnTooltip {
constructor(el) { constructor(el) {
@ -1631,4 +1683,20 @@ class HoverColumnTooltip {
} }
} }
// Simple function that calculates good color for zebra stripes.
function calcZebra(hex) {
if (!hex || hex.length !== 7) { return hex; }
// HSL: [HUE, SATURATION, LIGHTNESS]
const hsl = convert.hex.hsl(hex.substr(1));
// For bright color, we will make it darker. Value was picked by hand, to
// produce #f8f8f8f out of #ffffff.
if (hsl[2] > 50) { hsl[2] -= 2.6; }
// For darker color, we will make it brighter. Value was picked by hand to look
// good for the darkest colors in our palette.
else if (hsl[2] > 1) { hsl[2] += 11; }
// For very dark colors
else { hsl[2] += 16; }
return `#${convert.hsl.hex(hsl)}`;
}
module.exports = GridView; module.exports = GridView;

View File

@ -798,7 +798,8 @@ export class Importer extends DisposableWithEvents {
const editRow = vsi?.moveEditRowToCursor(); const editRow = vsi?.moveEditRowToCursor();
const editorHolder = openFormulaEditor({ const editorHolder = openFormulaEditor({
gristDoc: this._gristDoc, gristDoc: this._gristDoc,
field, column: field.column(),
editingFormula: field.editingFormula,
refElem, refElem,
editRow, editRow,
setupCleanup: this._setupFormulaEditorCleanup.bind(this), setupCleanup: this._setupFormulaEditorCleanup.bind(this),
@ -819,7 +820,7 @@ export class Importer extends DisposableWithEvents {
* focus. * focus.
*/ */
private _setupFormulaEditorCleanup( private _setupFormulaEditorCleanup(
owner: MultiHolder, _doc: GristDoc, field: ViewFieldRec, _saveEdit: () => Promise<unknown> owner: MultiHolder, _doc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
) { ) {
const saveEdit = () => _saveEdit().catch(reportError); const saveEdit = () => _saveEdit().catch(reportError);
@ -828,7 +829,7 @@ export class Importer extends DisposableWithEvents {
owner.onDispose(() => { owner.onDispose(() => {
this.off('importer_focus', saveEdit); this.off('importer_focus', saveEdit);
field.editingFormula(false); editingFormula(false);
}); });
} }

View File

@ -10,7 +10,7 @@ import {ColumnTransform} from 'app/client/components/ColumnTransform';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import * as TypeConversion from 'app/client/components/TypeConversion'; import * as TypeConversion from 'app/client/components/TypeConversion';
import {reportError} from 'app/client/models/errors'; import {reportError} from 'app/client/models/errors';
import {cssButtonRow} from 'app/client/ui/RightPanel'; import {cssButtonRow} from 'app/client/ui/RightPanelStyles';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {testId} from 'app/client/ui2018/cssVars'; import {testId} from 'app/client/ui2018/cssVars';
import {FieldBuilder} from 'app/client/widgets/FieldBuilder'; import {FieldBuilder} from 'app/client/widgets/FieldBuilder';

View File

@ -14,7 +14,7 @@ const {addToSort} = require('app/client/lib/sortUtil');
const {updatePositions} = require('app/client/lib/sortUtil'); const {updatePositions} = require('app/client/lib/sortUtil');
const {attachColumnFilterMenu} = require('app/client/ui/ColumnFilterMenu'); const {attachColumnFilterMenu} = require('app/client/ui/ColumnFilterMenu');
const {addFilterMenu} = require('app/client/ui/FilterBar'); const {addFilterMenu} = require('app/client/ui/FilterBar');
const {cssIcon, cssRow} = require('app/client/ui/RightPanel'); const {cssIcon, cssRow} = require('app/client/ui/RightPanelStyles');
const {basicButton, primaryButton} = require('app/client/ui2018/buttons'); const {basicButton, primaryButton} = require('app/client/ui2018/buttons');
const {labeledLeftSquareCheckbox} = require("app/client/ui2018/checkbox"); const {labeledLeftSquareCheckbox} = require("app/client/ui2018/checkbox");
const {colors} = require('app/client/ui2018/cssVars'); const {colors} = require('app/client/ui2018/cssVars');

View File

@ -4,3 +4,4 @@
import ViewConfigTab from 'app/client/components/ViewConfigTab'; import ViewConfigTab from 'app/client/components/ViewConfigTab';
import * as FieldConfig from 'app/client/ui/FieldConfig'; import * as FieldConfig from 'app/client/ui/FieldConfig';
export {ViewConfigTab, FieldConfig}; export {ViewConfigTab, FieldConfig};
export {ConditionalStyle} from 'app/client/widgets/ConditionalStyle';

View File

@ -19,7 +19,8 @@
selected fields - this still remains white. selected fields - this still remains white.
TODO: consider making this color the single source TODO: consider making this color the single source
*/ */
background: white; background: var(--grist-row-background-color, white);
color: var(--grist-row-color, black);
} }
.record.record-hlines { /* Overwrites style, width set on element */ .record.record-hlines { /* Overwrites style, width set on element */
@ -27,7 +28,7 @@
} }
.record.record-zebra.record-even { .record.record-zebra.record-even {
background-color: #f8f8f8; background-color: var(--grist-row-background-color-zebra, #f8f8f8);
} }
.record.record-add { .record.record-add {
@ -71,12 +72,12 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: var(--grist-diff-background-color, var(--grist-cell-background-color, unset)); background-color: var(--grist-diff-background-color, var(--grist-cell-background-color, unset));
--grist-actual-cell-color: var(--grist-diff-color, var(--grist-cell-color)); --grist-actual-cell-color: var(--grist-diff-color, var(--grist-cell-color, var(--grist-row-color)));
color: var(--grist-actual-cell-color, black); color: var(--grist-actual-cell-color, unset);
} }
.field.selected .field_clip { .field.selected .field_clip {
mix-blend-mode: darken; mix-blend-mode: luminosity;
} }
.field_clip.invalid, .field_clip.field-error-from-style { .field_clip.invalid, .field_clip.field-error-from-style {

View File

@ -0,0 +1,41 @@
import {ColumnRec, DocModel} from 'app/client/models/DocModel';
import {Style} from 'app/client/models/Styles';
import * as modelUtil from 'app/client/models/modelUtil';
export interface RuleOwner {
// Field or Section can have a list of conditional styling rules. Each style is a combination of a formula and options
// that must by applied. 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.
tableId: ko.Computed<string>;
// 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>;
}
export async function removeRule(docModel: DocModel, owner: RuleOwner, index: number) {
const col = owner.rulesCols.peek()[index];
if (!col) {
throw new Error(`There is no rule at index ${index}`);
}
const newStyles = owner.rulesStyles.peek()?.slice() ?? [];
if (newStyles.length >= index) {
newStyles.splice(index, 1);
} else {
console.debug(`There are not style options at index ${index}`);
}
await docModel.docData.bundleActions("Remove conditional rule", () =>
Promise.all([
owner.rulesStyles.setAndSave(newStyles),
docModel.docData.sendAction(['RemoveColumn', owner.tableId.peek(), col.colId.peek()])
])
);
}

View File

@ -1,6 +1,7 @@
import {ColumnRec, DocModel, IRowModel, refListRecords, 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 {formatterForRec} from 'app/client/models/entities/ColumnRec';
import * as modelUtil from 'app/client/models/modelUtil'; import * as modelUtil from 'app/client/models/modelUtil';
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
import {Style} from 'app/client/models/Styles'; import {Style} from 'app/client/models/Styles';
import * as UserType from 'app/client/widgets/UserType'; import * as UserType from 'app/client/widgets/UserType';
import {DocumentSettings} from 'app/common/DocumentSettings'; import {DocumentSettings} from 'app/common/DocumentSettings';
@ -9,7 +10,7 @@ import {createParser} from 'app/common/ValueParser';
import * as ko from 'knockout'; import * as ko from 'knockout';
// Represents a page entry in the tree of pages. // Represents a page entry in the tree of pages.
export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> { export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, RuleOwner {
viewSection: ko.Computed<ViewSectionRec>; viewSection: ko.Computed<ViewSectionRec>;
widthDef: modelUtil.KoSaveableObservable<number>; widthDef: modelUtil.KoSaveableObservable<number>;
@ -86,26 +87,6 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
// `formatter` formats actual cell values, e.g. a whole list from the display column. // `formatter` formats actual cell values, e.g. a whole list from the display column.
formatter: ko.Computed<BaseFormatter>; 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; createValueParser(): (value: string) => any;
// Helper which adds/removes/updates field's displayCol to match the formula. // Helper which adds/removes/updates field's displayCol to match the formula.
@ -253,6 +234,7 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
return docModel.docData.bundleActions("Update choices configuration", callback, actionOptions); return docModel.docData.bundleActions("Update choices configuration", callback, actionOptions);
}; };
this.tableId = ko.pureComputed(() => this.column().table().tableId());
this.rulesCols = refListRecords(docModel.columns, ko.pureComputed(() => this._fieldOrColumn().rules())); this.rulesCols = refListRecords(docModel.columns, ko.pureComputed(() => this._fieldOrColumn().rules()));
this.rulesColsIds = ko.pureComputed(() => this.rulesCols().map(c => c.colId())); this.rulesColsIds = ko.pureComputed(() => this.rulesCols().map(c => c.colId()));
this.rulesStyles = modelUtil.fieldWithDefault( this.rulesStyles = modelUtil.fieldWithDefault(
@ -274,25 +256,5 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
await docModel.docData.sendAction(action, `Update rules for ${this.colId.peek()}`); await docModel.docData.sendAction(action, `Update rules for ${this.colId.peek()}`);
}; };
// Helper method to remove a rule. this.removeRule = (index: number) => removeRule(docModel, this, index);
this.removeRule = async (index: number) => {
const col = this.rulesCols.peek()[index];
if (!col) {
throw new Error(`There is no rule at index ${index}`);
}
const tableData = docModel.dataTables[col.table.peek().tableId.peek()].tableData;
const newStyles = this.rulesStyles.peek().slice();
if (newStyles.length >= index) {
newStyles.splice(index, 1);
} else {
console.debug(`There are not style options at index ${index}`);
}
const callback = () =>
Promise.all([
this.rulesStyles.setAndSave(newStyles),
tableData.sendTableAction(['RemoveColumn', col.colId.peek()])
]);
const actionOptions = {nestInActiveBundle: this.column.peek().isTransforming.peek()};
await docModel.docData.bundleActions("Remove conditional rule", callback, actionOptions);
};
} }

View File

@ -8,6 +8,7 @@ import {
FilterRec, FilterRec,
IRowModel, IRowModel,
recordSet, recordSet,
refListRecords,
refRecord, refRecord,
TableRec, TableRec,
ViewFieldRec, ViewFieldRec,
@ -22,13 +23,14 @@ import {arrayRepeat} from 'app/common/gutil';
import {Sort} from 'app/common/SortSpec'; import {Sort} from 'app/common/SortSpec';
import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI'; import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI';
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap'; import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
import {Computed, Holder, Observable} from 'grainjs'; import {Computed, Holder, Observable} from 'grainjs';
import * as ko from 'knockout'; import * as ko from 'knockout';
import defaults = require('lodash/defaults'); import defaults = require('lodash/defaults');
// Represents a section of user views, now also known as a "page widget" (e.g. a view may contain // Represents a section of user views, now also known as a "page widget" (e.g. a view may contain
// a grid section and a chart section). // a grid section and a chart section).
export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> { export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleOwner {
viewFields: ko.Computed<KoArray<ViewFieldRec>>; viewFields: ko.Computed<KoArray<ViewFieldRec>>;
// All table columns associated with this view section, excluding hidden helper columns. // All table columns associated with this view section, excluding hidden helper columns.
@ -168,6 +170,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
// List of selected rows // List of selected rows
selectedRows: Observable<number[]>; selectedRows: Observable<number[]>;
editingFormula: ko.Computed<boolean>;
// Save all filters of fields/columns in the section. // Save all filters of fields/columns in the section.
saveFilters(): Promise<void>; saveFilters(): Promise<void>;
@ -235,7 +238,12 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
// All table columns associated with this view section, excluding any hidden helper columns. // All table columns associated with this view section, excluding any hidden helper columns.
this.columns = this.autoDispose(ko.pureComputed(() => this.table().columns().all().filter(c => !c.isHiddenCol()))); this.columns = this.autoDispose(ko.pureComputed(() => this.table().columns().all().filter(c => !c.isHiddenCol())));
this.editingFormula = ko.pureComputed({
read: () => docModel.editingFormula(),
write: val => {
docModel.editingFormula(val);
}
});
const defaultOptions = { const defaultOptions = {
verticalGridlines: true, verticalGridlines: true,
horizontalGridlines: true, horizontalGridlines: true,
@ -586,4 +594,25 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this.allowSelectBy = Observable.create(this, false); this.allowSelectBy = Observable.create(this, false);
this.selectedRows = Observable.create(this, []); this.selectedRows = Observable.create(this, []);
this.tableId = ko.pureComputed(() => this.table().tableId());
const rawSection = ko.pureComputed(() => this.table().rawViewSection());
this.rulesCols = refListRecords(docModel.columns, ko.pureComputed(() => rawSection().rules()));
this.rulesColsIds = ko.pureComputed(() => this.rulesCols().map(c => c.colId()));
this.rulesStyles = modelUtil.savingComputed({
read: () => rawSection().optionsObj.prop("rulesOptions")() ?? [],
write: (setter, val) => setter(rawSection().optionsObj.prop("rulesOptions"), val)
});
this.hasRules = ko.pureComputed(() => this.rulesCols().length > 0);
this.addEmptyRule = async () => {
const action = [
'AddEmptyRule',
this.tableId.peek(),
null,
null
];
await docModel.docData.sendAction(action, `Update rules for ${this.table.peek().tableId.peek()}`);
};
this.removeRule = (index: number) => removeRule(docModel, this, index);
} }

View File

@ -4,11 +4,12 @@ import * as kf from 'app/client/lib/koForm';
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap'; import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors'; import {reportError} from 'app/client/models/errors';
import {cssLabel, cssRow, cssSeparator, cssSubLabel, cssTextInput} from 'app/client/ui/RightPanel'; import {cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
import {cssDragRow, cssFieldEntry, cssFieldLabel} from 'app/client/ui/VisibleFieldsConfig'; import {cssDragRow, cssFieldEntry, cssFieldLabel} from 'app/client/ui/VisibleFieldsConfig';
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons'; import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
import {colors} from 'app/client/ui2018/cssVars'; import {colors, vars} from 'app/client/ui2018/cssVars';
import {cssDragger} from 'app/client/ui2018/draggableList'; import {cssDragger} from 'app/client/ui2018/draggableList';
import {textInput} from 'app/client/ui2018/editableLabel';
import {IconName} from 'app/client/ui2018/IconList'; import {IconName} from 'app/client/ui2018/IconList';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links'; import {cssLink} from 'app/client/ui2018/links';
@ -534,6 +535,13 @@ const cssRemoveIcon = styled(icon, `
} }
`); `);
// Additional text in label (greyed out)
const cssSubLabel = styled('span', `
text-transform: none;
font-size: ${vars.xsmallFontSize};
color: ${colors.slate};
`);
const cssAddMapping = styled('div', ` const cssAddMapping = styled('div', `
display: flex; display: flex;
cursor: pointer; cursor: pointer;
@ -548,3 +556,13 @@ const cssAddMapping = styled('div', `
--icon-color: ${colors.darkGreen}; --icon-color: ${colors.darkGreen};
} }
`); `);
const cssTextInput = styled(textInput, `
flex: 1 0 auto;
&:disabled {
color: ${colors.slate};
background-color: ${colors.lightGrey};
pointer-events: none;
}
`);

View File

@ -2,7 +2,7 @@ import {CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {ColumnRec} from 'app/client/models/entities/ColumnRec'; import {ColumnRec} from 'app/client/models/entities/ColumnRec';
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight'; import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
import {cssBlockedCursor, cssEmptySeparator, cssLabel, cssRow} from 'app/client/ui/RightPanel'; import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas'; import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas';
import {textButton} from 'app/client/ui2018/buttons'; import {textButton} from 'app/client/ui2018/buttons';
import {colors, testId} from 'app/client/ui2018/cssVars'; import {colors, testId} from 'app/client/ui2018/cssVars';
@ -382,3 +382,7 @@ const cssColTieConnectors = styled('div', `
border-left: none; border-left: none;
z-index: -1; z-index: -1;
`); `);
const cssEmptySeparator = styled('div', `
margin-top: 16px;
`);

View File

@ -1,6 +1,6 @@
import { ViewSectionRec } from "app/client/models/DocModel"; import { ViewSectionRec } from "app/client/models/DocModel";
import { KoSaveableObservable, setSaveValue } from "app/client/models/modelUtil"; import { KoSaveableObservable, setSaveValue } from "app/client/models/modelUtil";
import { cssLabel, cssRow } from "app/client/ui/RightPanel"; import { cssLabel, cssRow } from "app/client/ui/RightPanelStyles";
import { squareCheckbox } from "app/client/ui2018/checkbox"; import { squareCheckbox } from "app/client/ui2018/checkbox";
import { testId } from "app/client/ui2018/cssVars"; import { testId } from "app/client/ui2018/cssVars";
import { Computed, Disposable, dom, IDisposableOwner, styled } from "grainjs"; import { Computed, Disposable, dom, IDisposableOwner, styled } from "grainjs";

View File

@ -335,6 +335,17 @@ export class RightPanel extends Disposable {
return dom.create(GridOptions, activeSection); return dom.create(GridOptions, activeSection);
}), }),
domComputed((use) => {
if (use(this._pageWidgetType) !== 'record') { return null; }
return [
cssSeparator(),
cssLabel('ROW STYLE'),
domAsync(imports.loadViewPane().then(ViewPane =>
dom.create(ViewPane.ConditionalStyle, "Row Style", activeSection, this._gristDoc)
))
];
}),
dom.maybe((use) => use(this._pageWidgetType) === 'chart', () => [ dom.maybe((use) => use(this._pageWidgetType) === 'chart', () => [
cssLabel('CHART TYPE'), cssLabel('CHART TYPE'),
vct._buildChartConfigDom(), vct._buildChartConfigDom(),
@ -572,20 +583,14 @@ const cssBottomText = styled('span', `
padding: 4px 16px; padding: 4px 16px;
`); `);
export const cssLabel = styled('div', ` const cssLabel = styled('div', `
text-transform: uppercase; text-transform: uppercase;
margin: 16px 16px 12px 16px; margin: 16px 16px 12px 16px;
font-size: ${vars.xsmallFontSize}; font-size: ${vars.xsmallFontSize};
`); `);
// Additional text in label (greyed out)
export const cssSubLabel = styled('span', `
text-transform: none;
font-size: ${vars.xsmallFontSize};
color: ${colors.slate};
`);
export const cssRow = styled('div', ` const cssRow = styled('div', `
display: flex; display: flex;
margin: 8px 16px; margin: 8px 16px;
align-items: center; align-items: center;
@ -597,13 +602,8 @@ export const cssRow = styled('div', `
} }
`); `);
export const cssBlockedCursor = styled('span', `
&, & * {
cursor: not-allowed !important;
}
`);
export const cssButtonRow = styled(cssRow, ` const cssButtonRow = styled(cssRow, `
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
& > button { & > button {
@ -611,7 +611,7 @@ export const cssButtonRow = styled(cssRow, `
} }
`); `);
export const cssIcon = styled(icon, ` const cssIcon = styled(icon, `
flex: 0 0 auto; flex: 0 0 auto;
--icon-color: ${colors.slate}; --icon-color: ${colors.slate};
`); `);
@ -669,7 +669,7 @@ const cssHoverIcon = styled(icon, `
background-color: vars(--icon-color); background-color: vars(--icon-color);
`); `);
export const cssSubTabContainer = styled('div', ` const cssSubTabContainer = styled('div', `
height: 48px; height: 48px;
flex: none; flex: none;
display: flex; display: flex;
@ -677,7 +677,7 @@ export const cssSubTabContainer = styled('div', `
justify-content: space-between; justify-content: space-between;
`); `);
export const cssSubTab = styled('div', ` const cssSubTab = styled('div', `
color: ${colors.lightGreen}; color: ${colors.lightGreen};
flex: auto; flex: auto;
height: 100%; height: 100%;
@ -709,15 +709,11 @@ const cssTabContents = styled('div', `
overflow: auto; overflow: auto;
`); `);
export const cssSeparator = styled('div', ` const cssSeparator = styled('div', `
border-bottom: 1px solid ${colors.mediumGrey}; border-bottom: 1px solid ${colors.mediumGrey};
margin-top: 16px; margin-top: 16px;
`); `);
export const cssEmptySeparator = styled('div', `
margin-top: 16px;
`);
const cssConfigContainer = styled('div', ` const cssConfigContainer = styled('div', `
overflow: auto; overflow: auto;
--color-list-item: none; --color-list-item: none;
@ -765,7 +761,7 @@ const cssListItem = styled('li', `
padding: 4px 8px; padding: 4px 8px;
`); `);
export const cssTextInput = styled(textInput, ` const cssTextInput = styled(textInput, `
flex: 1 0 auto; flex: 1 0 auto;
&:disabled { &:disabled {

View File

@ -0,0 +1,45 @@
import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {styled} from 'grainjs';
export const cssIcon = styled(icon, `
flex: 0 0 auto;
--icon-color: ${colors.slate};
`);
export const cssLabel = styled('div', `
text-transform: uppercase;
margin: 16px 16px 12px 16px;
font-size: ${vars.xsmallFontSize};
`);
export const cssRow = styled('div', `
display: flex;
margin: 8px 16px;
align-items: center;
&-top-space {
margin-top: 24px;
}
&-disabled {
color: ${colors.slate};
}
`);
export const cssBlockedCursor = styled('span', `
&, & * {
cursor: not-allowed !important;
}
`);
export const cssButtonRow = styled(cssRow, `
margin-left: 0;
margin-right: 0;
& > button {
margin-left: 16px;
}
`);
export const cssSeparator = styled('div', `
border-bottom: 1px solid ${colors.mediumGrey};
margin-top: 16px;
`);

View File

@ -1,7 +1,7 @@
import type {ColumnRec} from 'app/client/models/entities/ColumnRec'; import type {ColumnRec} from 'app/client/models/entities/ColumnRec';
import type {TableRec} from 'app/client/models/entities/TableRec'; import type {TableRec} from 'app/client/models/entities/TableRec';
import {reportError} from 'app/client/models/errors'; import {reportError} from 'app/client/models/errors';
import {cssRow} from 'app/client/ui/RightPanel'; import {cssRow} from 'app/client/ui/RightPanelStyles';
import {shadowScroll} from 'app/client/ui/shadowScroll'; import {shadowScroll} from 'app/client/ui/shadowScroll';
import {basicButton, primaryButton} from "app/client/ui2018/buttons"; import {basicButton, primaryButton} from "app/client/ui2018/buttons";
import {labeledSquareCheckbox} from "app/client/ui2018/checkbox"; import {labeledSquareCheckbox} from "app/client/ui2018/checkbox";

View File

@ -19,19 +19,20 @@ export interface StyleOptions {
} }
export class ColorOption { export class ColorOption {
constructor( public color: Observable<string|undefined>;
public color: Observable<string|undefined>,
// If the color accepts undefined/empty as a value. Controls empty selector in the picker. // If the color accepts undefined/empty as a value. Controls empty selector in the picker.
public allowsNone: boolean = false, public allowsNone: boolean = false;
// Default color to show when value is empty or undefined (itself can be empty). // Default color to show when value is empty or undefined (itself can be empty).
public defaultColor: string = '', public defaultColor: string = '';
// Text to be shown in the picker when color is not set. // Text to be shown in the picker when color is not set.
public noneText: string = '', public noneText: string = '';
// Preview color to show when value is undefined. constructor(options: {
public previewNoneColor: string = '',) { color: Observable<string|undefined>,
if (defaultColor && allowsNone) { allowsNone?: boolean,
throw new Error("Allowing an empty value is not compatible with a default color"); defaultColor?: string,
} noneText?: string
}) {
Object.assign(this, options);
} }
} }
@ -53,8 +54,8 @@ export function colorSelect(
cssContent( cssContent(
cssButtonIcon( cssButtonIcon(
'T', 'T',
dom.style('color', use => use(textColor.color) || textColor.previewNoneColor), dom.style('color', use => use(textColor.color) || textColor.defaultColor),
dom.style('background-color', (use) => use(fillColor.color)?.slice(0, 7) || fillColor.previewNoneColor), dom.style('background-color', (use) => use(fillColor.color)?.slice(0, 7) || fillColor.defaultColor),
dom.cls('font-bold', use => use(styleOptions.fontBold) ?? false), dom.cls('font-bold', use => use(styleOptions.fontBold) ?? false),
dom.cls('font-italic', use => use(styleOptions.fontItalic) ?? false), dom.cls('font-italic', use => use(styleOptions.fontItalic) ?? false),
dom.cls('font-underline', use => use(styleOptions.fontUnderline) ?? false), dom.cls('font-underline', use => use(styleOptions.fontUnderline) ?? false),
@ -80,8 +81,8 @@ export function colorButton(
const { textColor, fillColor } = styleOptions; const { textColor, fillColor } = styleOptions;
const iconBtn = cssIconBtn( const iconBtn = cssIconBtn(
'T', 'T',
dom.style('color', use => use(textColor.color) || textColor.previewNoneColor), dom.style('color', use => use(textColor.color) || textColor.defaultColor),
dom.style('background-color', (use) => use(fillColor.color)?.slice(0, 7) || fillColor.previewNoneColor), dom.style('background-color', (use) => use(fillColor.color)?.slice(0, 7) || fillColor.defaultColor),
dom.cls('font-bold', use => use(styleOptions.fontBold) ?? false), dom.cls('font-bold', use => use(styleOptions.fontBold) ?? false),
dom.cls('font-italic', use => use(styleOptions.fontItalic) ?? false), dom.cls('font-italic', use => use(styleOptions.fontItalic) ?? false),
dom.cls('font-underline', use => use(styleOptions.fontUnderline) ?? false), dom.cls('font-underline', use => use(styleOptions.fontUnderline) ?? false),
@ -228,8 +229,6 @@ interface PickerComponentOptions {
defaultColor: string; defaultColor: string;
// Text to be shown in the picker when color is not set. // Text to be shown in the picker when color is not set.
noneText: string; noneText: string;
// Preview color to show when value is undefined.
previewNoneColor: string;
} }
class PickerComponent extends Disposable { class PickerComponent extends Disposable {

View File

@ -14,7 +14,7 @@ import {editableLabel} from 'app/client/ui2018/editableLabel';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {IModalControl, modal} from 'app/client/ui2018/modals'; import {IModalControl, modal} from 'app/client/ui2018/modals';
import {renderFileType} from 'app/client/widgets/AttachmentsWidget'; import {renderFileType} from 'app/client/widgets/AttachmentsWidget';
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor'; import {FieldOptions, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {CellValue} from 'app/common/DocActions'; import {CellValue} from 'app/common/DocActions';
import {SingleCell} from 'app/common/TableData'; import {SingleCell} from 'app/common/TableData';
import {clamp, encodeQueryParams} from 'app/common/gutil'; import {clamp, encodeQueryParams} from 'app/common/gutil';
@ -56,7 +56,7 @@ export class AttachmentsEditor extends NewBaseEditor {
private _index: LiveIndex; private _index: LiveIndex;
private _selected: Computed<Attachment|null>; private _selected: Computed<Attachment|null>;
constructor(options: Options) { constructor(options: FieldOptions) {
super(options); super(options);
const docData: DocData = options.gristDoc.docData; const docData: DocData = options.gristDoc.docData;

View File

@ -3,7 +3,7 @@ import {Computed, dom, fromKo, input, makeTestId, onElem, styled, TestId} from '
import * as commands from 'app/client/components/commands'; import * as commands from 'app/client/components/commands';
import {dragOverClass} from 'app/client/lib/dom'; import {dragOverClass} from 'app/client/lib/dom';
import {selectFiles, uploadFiles} from 'app/client/lib/uploads'; import {selectFiles, uploadFiles} from 'app/client/lib/uploads';
import {cssRow} from 'app/client/ui/RightPanel'; import {cssRow} from 'app/client/ui/RightPanelStyles';
import {colors, vars} from 'app/client/ui2018/cssVars'; import {colors, vars} from 'app/client/ui2018/cssVars';
import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget'; import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
import {encodeQueryParams} from 'app/common/gutil'; import {encodeQueryParams} from 'app/common/gutil';

View File

@ -1,265 +1,88 @@
import {allCommands} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {ColumnRec} from 'app/client/models/DocModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; 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 {textButton} from 'app/client/ui2018/buttons';
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect'; import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
import {colors} from 'app/client/ui2018/cssVars'; import {colors, vars} from 'app/client/ui2018/cssVars';
import {setupEditorCleanup} from 'app/client/widgets/FieldEditor'; import {ConditionalStyle} from 'app/client/widgets/ConditionalStyle';
import {cssError, openFormulaEditor} from 'app/client/widgets/FormulaEditor'; import {Disposable, dom, DomContents, fromKo, MultiHolder, Observable, styled} from 'grainjs';
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 { export class CellStyle extends Disposable {
protected textColor: Observable<string|undefined>; private _textColor: Observable<string|undefined>;
protected fillColor: Observable<string|undefined>; private _fillColor: Observable<string|undefined>;
protected fontBold: Observable<boolean|undefined>; private _fontBold: Observable<boolean|undefined>;
protected fontUnderline: Observable<boolean|undefined>; private _fontUnderline: Observable<boolean|undefined>;
protected fontItalic: Observable<boolean|undefined>; private _fontItalic: Observable<boolean|undefined>;
protected fontStrikethrough: Observable<boolean|undefined>; private _fontStrikethrough: Observable<boolean|undefined>;
// 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( constructor(
protected field: ViewFieldRec, private _field: ViewFieldRec,
protected gristDoc: GristDoc, private _gristDoc: GristDoc,
protected defaultTextColor: string private _defaultTextColor: string
) { ) {
super(); super();
this.textColor = fromKo(this.field.textColor); this._textColor = fromKo(this._field.textColor);
this.fillColor = fromKo(this.field.fillColor); this._fillColor = fromKo(this._field.fillColor);
this.fontBold = fromKo(this.field.fontBold); this._fontBold = fromKo(this._field.fontBold);
this.fontUnderline = fromKo(this.field.fontUnderline); this._fontUnderline = fromKo(this._field.fontUnderline);
this.fontItalic = fromKo(this.field.fontItalic); this._fontItalic = fromKo(this._field.fontItalic);
this.fontStrikethrough = fromKo(this.field.fontStrikethrough); this._fontStrikethrough = fromKo(this._field.fontStrikethrough);
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 { public buildDom(): DomContents {
const holder = new MultiHolder(); const holder = new MultiHolder();
return [ return [
cssLine(
cssLabel('CELL STYLE', dom.autoDispose(holder)), cssLabel('CELL STYLE', dom.autoDispose(holder)),
cssButton('Open row styles', dom.on('click', allCommands.viewTabOpen.run)),
),
cssRow( cssRow(
colorSelect( colorSelect(
{ {
textColor: new ColorOption(this.textColor, false, this.defaultTextColor), textColor: new ColorOption(
fillColor: new ColorOption(this.fillColor, true, '', 'none', '#FFFFFF'), { color: this._textColor, defaultColor: this._defaultTextColor, noneText: 'default'}
fontBold: this.fontBold, ),
fontItalic: this.fontItalic, fillColor: new ColorOption(
fontUnderline: this.fontUnderline, { color: this._fillColor, allowsNone: true, noneText: 'none'}
fontStrikethrough: this.fontStrikethrough ),
fontBold: this._fontBold,
fontItalic: this._fontItalic,
fontUnderline: this._fontUnderline,
fontStrikethrough: this._fontStrikethrough
}, },
// Calling `field.widgetOptionsJson.save()` saves both fill and text color settings. // Calling `field.widgetOptionsJson.save()` saves both fill and text color settings.
() => this.field.widgetOptionsJson.save() () => this._field.widgetOptionsJson.save()
) )
), ),
cssRow( dom.create(ConditionalStyle, "Cell Style", this._field, this._gristDoc)
{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 fontBold = this._buildStyleOption(owner, ruleIndex, 'fontBold');
const fontItalic = this._buildStyleOption(owner, ruleIndex, 'fontItalic');
const fontUnderline = this._buildStyleOption(owner, ruleIndex, 'fontUnderline');
const fontStrikethrough = this._buildStyleOption(owner, ruleIndex, 'fontStrikethrough');
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 null;
}
const value = record[use(column.colId)];
return value ?? null;
});
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: new ColorOption(textColor, true, '', 'default'),
fillColor: new ColorOption(fillColor, true, '', 'none'),
fontBold,
fontItalic,
fontUnderline,
fontStrikethrough
},
save,
'Cell style'
)
),
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<T extends keyof Style>(owner: Disposable, index: number, option: T) {
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 as any;
this.field.rulesStyles(list);
});
return obs;
} }
private _buildRuleFormula( const cssLine = styled('div', `
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; display: flex;
flex-direction: column; margin: 16px 16px 12px 16px;
gap: 12px; justify-content: space-between;
margin-top: 16px; align-items: baseline;
margin-bottom: 12px;
`); `);
const cssErrorBorder = styled('div', ` const cssLabel = styled('div', `
border-color: ${colors.error}; text-transform: uppercase;
font-size: ${vars.xsmallFontSize};
`); `);
const cssRuleError = styled(cssError, ` const cssButton = styled(textButton, `
margin: 2px 0px 10px 0px; font-size: ${vars.mediumFontSize};
`); `);
const cssColumnsRow = styled(cssRow, ` const cssRow = styled('div', `
align-items: flex-start;
margin-top: 0px;
margin-bottom: 0px;
`);
const cssLeftColumn = styled('div', `
overflow: hidden;
flex: 1;
display: flex; display: flex;
flex-direction: column; margin: 8px 16px;
gap: 4px; align-items: center;
&-top-space {
margin-top: 24px;
}
&-disabled {
color: ${colors.slate};
}
`); `);

View File

@ -42,8 +42,8 @@
position: relative; position: relative;
width: 3px; width: 3px;
height: 12px; height: 12px;
background-color: var(--grist-cell-color, #606060); background-color: var(--grist-actual-cell-color, #606060);
border: 1px solid var(--grist-cell-color, #606060); border: 1px solid var(--grist-actual-cell-color, #606060);
left: 3px; left: 3px;
top: -5px; top: -5px;
} }
@ -52,7 +52,7 @@
position: relative; position: relative;
width: 3px; width: 3px;
height: 3px; height: 3px;
background-color: var(--grist-cell-color, #606060); background-color: var(--grist-actual-cell-color, #606060);
border: 1px solid var(--grist-cell-color, #606060); border: 1px solid var(--grist-actual-cell-color, #606060);
top: 7px; top: 7px;
} }

View File

@ -19,6 +19,7 @@ CheckBox.prototype.buildConfigDom = function() {
CheckBox.prototype.buildDom = function(row) { CheckBox.prototype.buildDom = function(row) {
var value = row[this.field.colId()]; var value = row[this.field.colId()];
console.log(this);
return dom('div.field_clip', return dom('div.field_clip',
dom('div.widget_checkbox', dom('div.widget_checkbox',
dom.on('click', () => { dom.on('click', () => {
@ -28,14 +29,8 @@ CheckBox.prototype.buildDom = function(row) {
}), }),
dom('div.widget_checkmark', dom('div.widget_checkmark',
kd.show(value), kd.show(value),
dom('div.checkmark_kick', dom('div.checkmark_kick'),
kd.style('background-color', this.field.textColor), dom('div.checkmark_stem')
kd.style('border-color', this.field.textColor)
),
dom('div.checkmark_stem',
kd.style('background-color', this.field.textColor),
kd.style('border-color', this.field.textColor)
)
) )
) )
); );

View File

@ -6,7 +6,7 @@ import {colors, testId} from 'app/client/ui2018/cssVars';
import {menuCssClass} from 'app/client/ui2018/menus'; import {menuCssClass} from 'app/client/ui2018/menus';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons'; import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
import {EditorPlacement} from 'app/client/widgets/EditorPlacement'; import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor'; import {FieldOptions, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {csvEncodeRow} from 'app/common/csvFormat'; import {csvEncodeRow} from 'app/common/csvFormat';
import {CellValue} from "app/common/DocActions"; import {CellValue} from "app/common/DocActions";
import {decodeObject, encodeObject} from 'app/plugin/objtypes'; import {decodeObject, encodeObject} from 'app/plugin/objtypes';
@ -31,9 +31,9 @@ export class ChoiceListEditor extends NewBaseEditor {
private _tokenField: TokenField<ChoiceItem>; private _tokenField: TokenField<ChoiceItem>;
private _textInput: HTMLInputElement; private _textInput: HTMLInputElement;
private _dom: HTMLElement; private _dom: HTMLElement;
private _editorPlacement: EditorPlacement; private _editorPlacement!: EditorPlacement;
private _contentSizer: HTMLElement; // Invisible element to size the editor with all the tokens private _contentSizer: HTMLElement; // Invisible element to size the editor with all the tokens
private _inputSizer: HTMLElement; // Part of _contentSizer to size the text input private _inputSizer!: HTMLElement; // Part of _contentSizer to size the text input
private _alignment: string; private _alignment: string;
// Whether to include a button to show a new choice. // Whether to include a button to show a new choice.
@ -43,7 +43,7 @@ export class ChoiceListEditor extends NewBaseEditor {
private _choiceOptionsByName: ChoiceOptions; private _choiceOptionsByName: ChoiceOptions;
constructor(options: Options) { constructor(protected options: FieldOptions) {
super(options); super(options);
const choices: string[] = options.field.widgetOptionsJson.peek().choices || []; const choices: string[] = options.field.widgetOptionsJson.peek().choices || [];

View File

@ -1,5 +1,5 @@
import {IToken, TokenField} from 'app/client/lib/TokenField'; import {IToken, TokenField} from 'app/client/lib/TokenField';
import {cssBlockedCursor} from 'app/client/ui/RightPanel'; import {cssBlockedCursor} from 'app/client/ui/RightPanelStyles';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {colorButton, ColorOption} from 'app/client/ui2018/ColorSelect'; import {colorButton, ColorOption} from 'app/client/ui2018/ColorSelect';
import {colors, testId} from 'app/client/ui2018/cssVars'; import {colors, testId} from 'app/client/ui2018/cssVars';
@ -298,8 +298,9 @@ export class ChoiceListEntry extends Disposable {
dom.autoDispose(textColorObs), dom.autoDispose(textColorObs),
dom.autoDispose(choiceText), dom.autoDispose(choiceText),
colorButton({ colorButton({
textColor: new ColorOption(textColorObs, false, '#000000'), textColor: new ColorOption({color: textColorObs, defaultColor: '#000000'}),
fillColor: new ColorOption(fillColorObs, true, '', 'none', '#FFFFFF'), fillColor: new ColorOption(
{color: fillColorObs, allowsNone: true, noneText: 'none', defaultColor: '#FFFFFF'}),
fontBold: fontBoldObs, fontBold: fontBoldObs,
fontItalic: fontItalicObs, fontItalic: fontItalicObs,
fontUnderline: fontUnderlineObs, fontUnderline: fontUnderlineObs,

View File

@ -2,7 +2,7 @@ import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {KoSaveableObservable} from 'app/client/models/modelUtil'; import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {Style} from 'app/client/models/Styles'; import {Style} from 'app/client/models/Styles';
import {cssLabel, cssRow} from 'app/client/ui/RightPanel'; import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {testId} from 'app/client/ui2018/cssVars'; import {testId} from 'app/client/ui2018/cssVars';
import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry'; import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry';
import {choiceToken, DEFAULT_FILL_COLOR, DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken'; import {choiceToken, DEFAULT_FILL_COLOR, DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';

View File

@ -0,0 +1,266 @@
import {GristDoc} from 'app/client/components/GristDoc';
import {ColumnRec} from 'app/client/models/DocModel';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {RuleOwner} from 'app/client/models/RuleOwner';
import {Style} from 'app/client/models/Styles';
import {cssFieldFormula} from 'app/client/ui/FieldConfig';
import {textButton} from 'app/client/ui2018/buttons';
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
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, makeTestId, Observable, styled} from 'grainjs';
import debounce = require('lodash/debounce');
const testId = makeTestId('test-widget-style-');
export class ConditionalStyle extends Disposable {
// Holds data from currently selected record (holds data only when this field has conditional styles).
private _currentRecord: Computed<RowRecord | undefined>;
// Helper field for refreshing current record data.
private _dataChangeTrigger = Observable.create(this, 0);
constructor(
private _label: string,
private _ruleOwner: RuleOwner,
private _gristDoc: GristDoc,
) {
super();
this._currentRecord = Computed.create(this, use => {
if (!use(this._ruleOwner.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(_ruleOwner.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(_ruleOwner.tableId);
const tableData = _gristDoc.docData.getTable(tableId);
return tableData ? use.owner.autoDispose(tableData.tableActionEmitter.addListener(debouncedUpdate)) : null;
});
}
public buildDom(): DomContents {
return [
cssRow(
{ style: 'margin-top: 16px' },
textButton(
'Add conditional style',
testId('add-conditional-style'),
dom.on('click', () => this._ruleOwner.addEmptyRule())
),
dom.hide(use => use(this._ruleOwner.hasRules))
),
dom.domComputedOwned(
use => use(this._ruleOwner.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 fontBold = this._buildStyleOption(owner, ruleIndex, 'fontBold');
const fontItalic = this._buildStyleOption(owner, ruleIndex, 'fontItalic');
const fontUnderline = this._buildStyleOption(owner, ruleIndex, 'fontUnderline');
const fontStrikethrough = this._buildStyleOption(owner, ruleIndex, 'fontStrikethrough');
const save = async () => {
// This will save both options.
await this._ruleOwner.rulesStyles.save();
};
const currentValue = Computed.create(owner, use => {
const record = use(this._currentRecord);
if (!record) {
return null;
}
const value = record[use(column.colId)];
return value ?? null;
});
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: new ColorOption({color:textColor, allowsNone: true, noneText: 'default'}),
fillColor: new ColorOption({color:fillColor, allowsNone: true, noneText: 'none'}),
fontBold,
fontItalic,
fontUnderline,
fontStrikethrough
},
save,
this._label || 'Conditional Style'
)
),
cssRemoveButton(
'Remove',
testId(`remove-rule-${ruleIndex}`),
dom.on('click', () => this._ruleOwner.removeRule(ruleIndex))
)
)
);
})
)
),
cssRow(
textButton('Add another rule',
dom.on('click', () => this._ruleOwner.addEmptyRule()),
testId('add-another-rule'),
),
dom.show(use => use(this._ruleOwner.hasRules))
),
];
}
private _buildStyleOption<T extends keyof Style>(owner: Disposable, index: number, option: T) {
const obs = Computed.create(owner, use => {
const styles = use(this._ruleOwner.rulesStyles);
return styles?.[index]?.[option];
});
obs.onWrite(value => {
const list = Array.from(this._ruleOwner.rulesStyles.peek() ?? []);
list[index] = list[index] ?? {};
list[index][option] = value;
this._ruleOwner.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 section = this._gristDoc.viewModel.activeSection();
const vsi = section.viewInstance();
const editorHolder = openFormulaEditor({
gristDoc: this._gristDoc,
editingFormula: section.editingFormula,
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 cssIcon = styled(icon, `
flex: 0 0 auto;
--icon-color: ${colors.slate};
`);
const cssLabel = styled('div', `
text-transform: uppercase;
margin: 16px 16px 12px 16px;
font-size: ${vars.xsmallFontSize};
`);
const cssRow = styled('div', `
display: flex;
margin: 8px 16px;
align-items: center;
&-top-space {
margin-top: 24px;
}
&-disabled {
color: ${colors.slate};
}
`);
const cssRemoveButton = styled(cssIcon, `
flex: none;
margin: 6px;
margin-right: 0px;
transform: translateY(4px);
cursor: pointer;
--icon-color: ${colors.slate};
&:hover {
--icon-color: ${colors.lightGreen};
}
`);
const cssLineLabel = styled(cssLabel, `
margin-top: 0px;
margin-bottom: 0px;
`);
const cssRuleList = styled('div', `
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
margin-bottom: 12px;
`);
const cssErrorBorder = styled('div', `
border-color: ${colors.error};
`);
const cssRuleError = styled(cssError, `
margin: 2px 0px 10px 0px;
`);
const cssColumnsRow = styled(cssRow, `
align-items: flex-start;
margin-top: 0px;
margin-bottom: 0px;
`);
const cssLeftColumn = styled('div', `
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
`);

View File

@ -8,7 +8,7 @@ var AbstractWidget = require('./AbstractWidget');
const {fromKoSave} = require('app/client/lib/fromKoSave'); const {fromKoSave} = require('app/client/lib/fromKoSave');
const {alignmentSelect} = require('app/client/ui2018/buttonSelect'); const {alignmentSelect} = require('app/client/ui2018/buttonSelect');
const {cssRow, cssLabel} = require('app/client/ui/RightPanel'); const {cssRow, cssLabel} = require('app/client/ui/RightPanelStyles');
const {cssTextInput} = require("app/client/ui2018/editableLabel"); const {cssTextInput} = require("app/client/ui2018/editableLabel");
const {styled, fromKo} = require('grainjs'); const {styled, fromKo} = require('grainjs');
const {select} = require('app/client/ui2018/menus'); const {select} = require('app/client/ui2018/menus');

View File

@ -10,7 +10,7 @@ var gutil = require('app/common/gutil');
const {fromKoSave} = require('app/client/lib/fromKoSave'); const {fromKoSave} = require('app/client/lib/fromKoSave');
const {alignmentSelect} = require('app/client/ui2018/buttonSelect'); const {alignmentSelect} = require('app/client/ui2018/buttonSelect');
const {cssRow, cssLabel} = require('app/client/ui/RightPanel'); const {cssRow, cssLabel} = require('app/client/ui/RightPanelStyles');
const {cssTextInput} = require("app/client/ui2018/editableLabel"); const {cssTextInput} = require("app/client/ui2018/editableLabel");
const {dom: gdom, styled, fromKo} = require('grainjs'); const {dom: gdom, styled, fromKo} = require('grainjs');
const {select} = require('app/client/ui2018/menus'); const {select} = require('app/client/ui2018/menus');

View File

@ -15,7 +15,7 @@ import { ColumnRec, DocModel, ViewFieldRec } from 'app/client/models/DocModel';
import { SaveableObjObservable, setSaveValue } from 'app/client/models/modelUtil'; import { SaveableObjObservable, setSaveValue } from 'app/client/models/modelUtil';
import { CombinedStyle, Style } from 'app/client/models/Styles'; import { CombinedStyle, Style } from 'app/client/models/Styles';
import { FieldSettingsMenu } from 'app/client/ui/FieldMenus'; import { FieldSettingsMenu } from 'app/client/ui/FieldMenus';
import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanel'; import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanelStyles';
import { buttonSelect } from 'app/client/ui2018/buttonSelect'; import { buttonSelect } from 'app/client/ui2018/buttonSelect';
import { colors } from 'app/client/ui2018/cssVars'; import { colors } from 'app/client/ui2018/cssVars';
import { IOptionFull, menu, select } from 'app/client/ui2018/menus'; import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
@ -497,7 +497,7 @@ export class FieldBuilder extends Disposable {
// If user set white color - remove it to play nice with zebra strips. // If user set white color - remove it to play nice with zebra strips.
// If there is no color we are using fully transparent white color (for tests mainly). // If there is no color we are using fully transparent white color (for tests mainly).
fill = fill ? fill.toUpperCase() : fill; fill = fill ? fill.toUpperCase() : fill;
return (fill === '#FFFFFF' ? '' : fill) || '#FFFFFF00'; return (fill === '#FFFFFF' ? '' : fill) || '';
})).onlyNotifyUnequal(); })).onlyNotifyUnequal();
const fontBold = buildFontOptions(this, computedRule, 'fontBold'); const fontBold = buildFontOptions(this, computedRule, 'fontBold');
@ -622,7 +622,8 @@ export class FieldBuilder extends Disposable {
onCancel?: () => void) { onCancel?: () => void) {
const editorHolder = openFormulaEditor({ const editorHolder = openFormulaEditor({
gristDoc: this.gristDoc, gristDoc: this.gristDoc,
field: this.field, column: this.field.column(),
editingFormula: this.field.editingFormula,
setupCleanup: setupEditorCleanup, setupCleanup: setupEditorCleanup,
editRow, editRow,
refElem, refElem,

View File

@ -161,7 +161,7 @@ export class FieldEditor extends Disposable {
// for readonly field we don't need to do anything special // for readonly field we don't need to do anything special
if (!options.readonly) { if (!options.readonly) {
setupEditorCleanup(this, this._gristDoc, this._field, this._saveEdit); setupEditorCleanup(this, this._gristDoc, this._field.editingFormula, this._saveEdit);
} else { } else {
setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit()); setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit());
} }
@ -203,6 +203,8 @@ export class FieldEditor extends Disposable {
const editor = this._editorHolder.autoDispose(editorCtor.create({ const editor = this._editorHolder.autoDispose(editorCtor.create({
gristDoc: this._gristDoc, gristDoc: this._gristDoc,
field: this._field, field: this._field,
column: this._field.column(), // needed for FormulaEditor
editingFormula: this._field.editingFormula, // needed for Formula editor
cellValue, cellValue,
rowId: this._editRow.id(), rowId: this._editRow.id(),
formulaError: error, formulaError: error,
@ -394,7 +396,7 @@ function setupReadonlyEditorCleanup(
* - Arrange for UnsavedChange protection against leaving the page with unsaved changes. * - Arrange for UnsavedChange protection against leaving the page with unsaved changes.
*/ */
export function setupEditorCleanup( export function setupEditorCleanup(
owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, _saveEdit: () => Promise<unknown> owner: MultiHolder, gristDoc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
) { ) {
const saveEdit = () => _saveEdit().catch(reportError); const saveEdit = () => _saveEdit().catch(reportError);
@ -408,6 +410,6 @@ export function setupEditorCleanup(
owner.onDispose(() => { owner.onDispose(() => {
gristDoc.app.off('clipboard_focus', saveEdit); gristDoc.app.off('clipboard_focus', saveEdit);
// Unset field.editingFormula flag when the editor closes. // Unset field.editingFormula flag when the editor closes.
field.editingFormula(false); editingFormula(false);
}); });
} }

View File

@ -23,7 +23,8 @@ const minFormulaErrorWidth = 400;
export interface IFormulaEditorOptions extends Options { export interface IFormulaEditorOptions extends Options {
cssClass?: string; cssClass?: string;
editingFormula?: ko.Computed<boolean>, editingFormula: ko.Computed<boolean>,
column: ColumnRec,
} }
@ -42,12 +43,12 @@ export class FormulaEditor extends NewBaseEditor {
private _formulaEditor: any; private _formulaEditor: any;
private _commandGroup: any; private _commandGroup: any;
private _dom: HTMLElement; private _dom: HTMLElement;
private _editorPlacement: EditorPlacement; private _editorPlacement!: EditorPlacement;
constructor(options: IFormulaEditorOptions) { constructor(options: IFormulaEditorOptions) {
super(options); super(options);
const editingFormula = options.editingFormula || options.field.editingFormula; const editingFormula = options.editingFormula;
const initialValue = undef(options.state as string | undefined, options.editValue, String(options.cellValue)); const initialValue = undef(options.state as string | undefined, options.editValue, String(options.cellValue));
// create editor state observable (used by draft and latest position memory) // create editor state observable (used by draft and latest position memory)
@ -56,7 +57,7 @@ export class FormulaEditor extends NewBaseEditor {
this._formulaEditor = AceEditor.create({ this._formulaEditor = AceEditor.create({
// A bit awkward, but we need to assume calcSize is not used until attach() has been called // A bit awkward, but we need to assume calcSize is not used until attach() has been called
// and _editorPlacement created. // and _editorPlacement created.
field: options.field, column: options.column,
calcSize: this._calcSize.bind(this), calcSize: this._calcSize.bind(this),
gristDoc: options.gristDoc, gristDoc: options.gristDoc,
saveValueOnBlurEvent: !options.readonly, saveValueOnBlurEvent: !options.readonly,
@ -129,7 +130,7 @@ export class FormulaEditor extends NewBaseEditor {
aceObj.gotoLine(0, 0); // By moving, ace editor won't highlight anything 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. // This catches any change to the value including e.g. via backspace or paste.
aceObj.once("change", () => editingFormula(true)); aceObj.once("change", () => editingFormula?.(true));
}) })
), ),
(options.formulaError ? [ (options.formulaError ? [
@ -268,9 +269,10 @@ function _isInIdentifier(line: string, column: number) {
*/ */
export function openFormulaEditor(options: { export function openFormulaEditor(options: {
gristDoc: GristDoc, gristDoc: GristDoc,
field: ViewFieldRec,
// Associated formula from a different column (for example style rule). // Associated formula from a different column (for example style rule).
column?: ColumnRec, column?: ColumnRec,
field?: ViewFieldRec,
editingFormula?: ko.Computed<boolean>,
// Needed to get exception value, if any. // Needed to get exception value, if any.
editRow?: DataRowModel, editRow?: DataRowModel,
// Element over which to position the editor. // Element over which to position the editor.
@ -282,13 +284,17 @@ export function openFormulaEditor(options: {
setupCleanup: ( setupCleanup: (
owner: MultiHolder, owner: MultiHolder,
doc: GristDoc, doc: GristDoc,
field: ViewFieldRec, editingFormula: ko.Computed<boolean>,
save: () => Promise<void> save: () => Promise<void>
) => void, ) => void,
}): Disposable { }): Disposable {
const {gristDoc, field, editRow, refElem, setupCleanup} = options; const {gristDoc, editRow, refElem, setupCleanup} = options;
const holder = MultiHolder.create(null); const holder = MultiHolder.create(null);
const column = options.column ? options.column : field.origCol(); const column = options.column ?? options.field?.column();
if (!column) {
throw new Error('Column or field is required');
}
// AsyncOnce ensures it's called once even if triggered multiple times. // AsyncOnce ensures it's called once even if triggered multiple times.
const saveEdit = asyncOnce(async () => { const saveEdit = asyncOnce(async () => {
@ -316,7 +322,8 @@ export function openFormulaEditor(options: {
// Replace the item in the Holder with a new one, disposing the previous one. // Replace the item in the Holder with a new one, disposing the previous one.
const editor = FormulaEditor.create(holder, { const editor = FormulaEditor.create(holder, {
gristDoc, gristDoc,
field, column,
editingFormula: options.editingFormula,
rowId: editRow ? editRow.id() : 0, rowId: editRow ? editRow.id() : 0,
cellValue: column.formula(), cellValue: column.formula(),
formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined, formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
@ -325,17 +332,23 @@ export function openFormulaEditor(options: {
commands: editCommands, commands: editCommands,
cssClass: 'formula_editor_sidepane', cssClass: 'formula_editor_sidepane',
readonly : false readonly : false
}); } as IFormulaEditorOptions);
editor.attach(refElem); editor.attach(refElem);
const editingFormula = options.editingFormula ?? options?.field?.editingFormula;
if (!editingFormula) {
throw new Error('editingFormula is required');
}
// When formula is empty enter formula-editing mode (highlight formula icons; click on a column inserts its ID). // 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 // This function is used for primarily for switching between different column behaviors, so we want to enter full
// edit mode right away. // edit mode right away.
// TODO: consider converting it to parameter, when this will be used in different scenarios. // TODO: consider converting it to parameter, when this will be used in different scenarios.
if (!column.formula()) { if (!column.formula()) {
field.editingFormula(true); editingFormula(true);
} }
setupCleanup(holder, gristDoc, field, saveEdit); setupCleanup(holder, gristDoc, editingFormula, saveEdit);
return holder; return holder;
} }

View File

@ -1,4 +1,4 @@
import {Options} from 'app/client/widgets/NewBaseEditor'; import {FieldOptions} from 'app/client/widgets/NewBaseEditor';
import {NTextEditor} from 'app/client/widgets/NTextEditor'; import {NTextEditor} from 'app/client/widgets/NTextEditor';
/** /**
@ -6,7 +6,7 @@ import {NTextEditor} from 'app/client/widgets/NTextEditor';
* to the user how links should be formatted. * to the user how links should be formatted.
*/ */
export class HyperLinkEditor extends NTextEditor { export class HyperLinkEditor extends NTextEditor {
constructor(options: Options) { constructor(options: FieldOptions) {
super(options); super(options);
this.textInput.setAttribute('placeholder', '[link label] url'); this.textInput.setAttribute('placeholder', '[link label] url');
} }

View File

@ -2,7 +2,7 @@ import { fromKoSave } from 'app/client/lib/fromKoSave';
import { findLinks } from 'app/client/lib/textUtils'; import { findLinks } from 'app/client/lib/textUtils';
import { DataRowModel } from 'app/client/models/DataRowModel'; import { DataRowModel } from 'app/client/models/DataRowModel';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec'; import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
import { cssRow } from 'app/client/ui/RightPanel'; import { cssRow } from 'app/client/ui/RightPanelStyles';
import { alignmentSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect'; import { alignmentSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect';
import { colors, testId } from 'app/client/ui2018/cssVars'; import { colors, testId } from 'app/client/ui2018/cssVars';
import { cssIconBackground, icon } from 'app/client/ui2018/icons'; import { cssIconBackground, icon } from 'app/client/ui2018/icons';

View File

@ -5,7 +5,7 @@ import {createGroup} from 'app/client/components/commands';
import {testId} from 'app/client/ui2018/cssVars'; import {testId} from 'app/client/ui2018/cssVars';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons'; import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement'; import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor'; import {FieldOptions, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {CellValue} from "app/common/DocActions"; import {CellValue} from "app/common/DocActions";
import {undef} from 'app/common/gutil'; import {undef} from 'app/common/gutil';
import {dom, Observable} from 'grainjs'; import {dom, Observable} from 'grainjs';
@ -19,13 +19,13 @@ export class NTextEditor extends NewBaseEditor {
protected commandGroup: any; protected commandGroup: any;
private _dom: HTMLElement; private _dom: HTMLElement;
private _editorPlacement: EditorPlacement; private _editorPlacement!: EditorPlacement;
private _contentSizer: HTMLElement; private _contentSizer: HTMLElement;
private _alignment: string; private _alignment: string;
// Note: TextEditor supports also options.placeholder for use by derived classes, but this is // Note: TextEditor supports also options.placeholder for use by derived classes, but this is
// easy to apply to this.textInput without needing a separate option. // easy to apply to this.textInput without needing a separate option.
constructor(options: Options) { constructor(protected options: FieldOptions) {
super(options); super(options);
const initialValue: string = undef( const initialValue: string = undef(

View File

@ -15,7 +15,6 @@ export interface IEditorCommandGroup {
export interface Options { export interface Options {
gristDoc: GristDoc; gristDoc: GristDoc;
field: ViewFieldRec;
cellValue: CellValue; cellValue: CellValue;
rowId: number; rowId: number;
formulaError?: Observable<CellValue>; formulaError?: Observable<CellValue>;
@ -26,6 +25,10 @@ export interface Options {
readonly: boolean; readonly: boolean;
} }
export interface FieldOptions extends Options {
field: ViewFieldRec;
}
/** /**
* Required parameters: * Required parameters:
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited. * @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
@ -42,7 +45,7 @@ export abstract class NewBaseEditor extends Disposable {
* updated to new-style Disposables. * updated to new-style Disposables.
*/ */
public static create<Opt extends Options>(owner: IDisposableOwner|null, options: Opt): NewBaseEditor; public static create<Opt extends Options>(owner: IDisposableOwner|null, options: Opt): NewBaseEditor;
public static create(options: Options): NewBaseEditor; public static create<Opt extends Options>(options: Opt): NewBaseEditor;
public static create(ownerOrOptions: any, options?: any): NewBaseEditor { public static create(ownerOrOptions: any, options?: any): NewBaseEditor {
return options ? return options ?
Disposable.create.call(this as any, ownerOrOptions, options) : Disposable.create.call(this as any, ownerOrOptions, options) :

View File

@ -3,7 +3,7 @@
*/ */
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors'; import {reportError} from 'app/client/models/errors';
import {cssLabel, cssRow} from 'app/client/ui/RightPanel'; import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect'; import {ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
import {colors, testId} from 'app/client/ui2018/cssVars'; import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';

View File

@ -1,6 +1,6 @@
import {DataRowModel} from 'app/client/models/DataRowModel'; import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {cssLabel, cssRow} from 'app/client/ui/RightPanel'; import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {colors, testId} from 'app/client/ui2018/cssVars'; import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {IOptionFull, select} from 'app/client/ui2018/menus'; import {IOptionFull, select} from 'app/client/ui2018/menus';

View File

@ -5,7 +5,7 @@ import { reportError } from 'app/client/models/errors';
import { colors, testId, vars } from 'app/client/ui2018/cssVars'; import { colors, testId, vars } from 'app/client/ui2018/cssVars';
import { icon } from 'app/client/ui2018/icons'; import { icon } from 'app/client/ui2018/icons';
import { menuCssClass } from 'app/client/ui2018/menus'; import { menuCssClass } from 'app/client/ui2018/menus';
import { Options } from 'app/client/widgets/NewBaseEditor'; import { FieldOptions } from 'app/client/widgets/NewBaseEditor';
import { NTextEditor } from 'app/client/widgets/NTextEditor'; import { NTextEditor } from 'app/client/widgets/NTextEditor';
import { nocaseEqual, ReferenceUtils } from 'app/client/lib/ReferenceUtils'; import { nocaseEqual, ReferenceUtils } from 'app/client/lib/ReferenceUtils';
import { undef } from 'app/common/gutil'; import { undef } from 'app/common/gutil';
@ -21,7 +21,7 @@ export class ReferenceEditor extends NTextEditor {
private _autocomplete?: Autocomplete<ICellItem>; private _autocomplete?: Autocomplete<ICellItem>;
private _utils: ReferenceUtils; private _utils: ReferenceUtils;
constructor(options: Options) { constructor(options: FieldOptions) {
super(options); super(options);
const docData = options.gristDoc.docData; const docData = options.gristDoc.docData;

View File

@ -8,7 +8,7 @@ import { menuCssClass } from 'app/client/ui2018/menus';
import { cssChoiceToken } from 'app/client/widgets/ChoiceToken'; import { cssChoiceToken } from 'app/client/widgets/ChoiceToken';
import { createMobileButtons, getButtonMargins } from 'app/client/widgets/EditorButtons'; import { createMobileButtons, getButtonMargins } from 'app/client/widgets/EditorButtons';
import { EditorPlacement } from 'app/client/widgets/EditorPlacement'; import { EditorPlacement } from 'app/client/widgets/EditorPlacement';
import { NewBaseEditor, Options } from 'app/client/widgets/NewBaseEditor'; import { FieldOptions, NewBaseEditor } from 'app/client/widgets/NewBaseEditor';
import { cssRefList, renderACItem } from 'app/client/widgets/ReferenceEditor'; import { cssRefList, renderACItem } from 'app/client/widgets/ReferenceEditor';
import { ReferenceUtils } from 'app/client/lib/ReferenceUtils'; import { ReferenceUtils } from 'app/client/lib/ReferenceUtils';
import { csvEncodeRow } from 'app/common/csvFormat'; import { csvEncodeRow } from 'app/common/csvFormat';
@ -46,13 +46,13 @@ export class ReferenceListEditor extends NewBaseEditor {
private _tokenField: TokenField<ReferenceItem>; private _tokenField: TokenField<ReferenceItem>;
private _textInput: HTMLInputElement; private _textInput: HTMLInputElement;
private _dom: HTMLElement; private _dom: HTMLElement;
private _editorPlacement: EditorPlacement; private _editorPlacement!: EditorPlacement;
private _contentSizer: HTMLElement; // Invisible element to size the editor with all the tokens private _contentSizer: HTMLElement; // Invisible element to size the editor with all the tokens
private _inputSizer: HTMLElement; // Part of _contentSizer to size the text input private _inputSizer!: HTMLElement; // Part of _contentSizer to size the text input
private _alignment: string; private _alignment: string;
private _utils: ReferenceUtils; private _utils: ReferenceUtils;
constructor(options: Options) { constructor(protected options: FieldOptions) {
super(options); super(options);
const docData = options.gristDoc.docData; const docData = options.gristDoc.docData;

View File

@ -33,7 +33,7 @@
} }
.switch_on > .switch_slider { .switch_on > .switch_slider {
background-color: var(--grist-cell-color, #2CB0AF); background-color: var(--grist-actual-cell-color, #2CB0AF);
} }
.switch_on > .switch_circle { .switch_on > .switch_circle {

View File

@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
// tslint:disable:object-literal-key-quotes // tslint:disable:object-literal-key-quotes
export const SCHEMA_VERSION = 31; export const SCHEMA_VERSION = 32;
export const schema = { export const schema = {
@ -115,6 +115,7 @@ export const schema = {
linkSrcColRef : "Ref:_grist_Tables_column", linkSrcColRef : "Ref:_grist_Tables_column",
linkTargetColRef : "Ref:_grist_Tables_column", linkTargetColRef : "Ref:_grist_Tables_column",
embedId : "Text", embedId : "Text",
rules : "RefList:_grist_Tables_column",
}, },
"_grist_Views_section_field": { "_grist_Views_section_field": {
@ -306,6 +307,7 @@ export interface SchemaTypes {
linkSrcColRef: number; linkSrcColRef: number;
linkTargetColRef: number; linkTargetColRef: number;
embedId: string; embedId: string;
rules: [GristObjCode.List, ...number[]]|null;
}; };
"_grist_Views_section_field": { "_grist_Views_section_field": {

View File

@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; 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 ''); 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,'','','',31,'',''); INSERT INTO _grist_DocInfo VALUES(1,'','','',32,'','');
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" (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, "rules" TEXT DEFAULT NULL, "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_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);
@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS "_grist_TabItems" (id INTEGER PRIMARY KEY, "tableRef"
CREATE TABLE IF NOT EXISTS "_grist_TabBar" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "tabPos" NUMERIC DEFAULT 1e999); CREATE TABLE IF NOT EXISTS "_grist_TabBar" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "tabPos" NUMERIC DEFAULT 1e999);
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_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" (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" (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 '', "rules" TEXT DEFAULT NULL);
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_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_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_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
@ -42,7 +42,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; 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 ''); 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,'','','',31,'',''); INSERT INTO _grist_DocInfo VALUES(1,'','','',32,'','');
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" (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); 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, "rules" TEXT DEFAULT NULL, "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);
@ -61,9 +61,9 @@ CREATE TABLE IF NOT EXISTS "_grist_Pages" (id INTEGER PRIMARY KEY, "viewRef" INT
INSERT INTO _grist_Pages VALUES(1,1,0,1); INSERT INTO _grist_Pages VALUES(1,1,0,1);
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" (id INTEGER PRIMARY KEY, "name" TEXT DEFAULT '', "type" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '');
INSERT INTO _grist_Views VALUES(1,'Table1','raw_data',''); 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 ''); 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 '', "rules" TEXT DEFAULT NULL);
INSERT INTO _grist_Views_section VALUES(1,1,1,'record','',100,1,'','','','','','[]',0,0,0,''); INSERT INTO _grist_Views_section VALUES(1,1,1,'record','',100,1,'','','','','','[]',0,0,0,'',NULL);
INSERT INTO _grist_Views_section VALUES(2,1,0,'record','',100,1,'','','','','','',0,0,0,''); INSERT INTO _grist_Views_section VALUES(2,1,0,'record','',100,1,'','','','','','',0,0,0,'',NULL);
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_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(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(2,1,2,3,0,'',0,0,'',NULL);

View File

@ -99,6 +99,7 @@
"bowser": "2.7.0", "bowser": "2.7.0",
"brace": "0.11.1", "brace": "0.11.1",
"collect-js-deps": "^0.1.1", "collect-js-deps": "^0.1.1",
"color-convert": "2.0.1",
"commander": "9.3.0", "commander": "9.3.0",
"components-jqueryui": "1.12.1", "components-jqueryui": "1.12.1",
"connect-redis": "3.4.0", "connect-redis": "3.4.0",

View File

@ -89,6 +89,7 @@ class MetaTableExtras(object):
usedByFields = _record_set('_grist_Views_section_field', 'displayCol') usedByFields = _record_set('_grist_Views_section_field', 'displayCol')
ruleUsedByCols = _record_ref_list_set('_grist_Tables_column', 'rules') ruleUsedByCols = _record_ref_list_set('_grist_Tables_column', 'rules')
ruleUsedByFields = _record_ref_list_set('_grist_Views_section_field', 'rules') ruleUsedByFields = _record_ref_list_set('_grist_Views_section_field', 'rules')
ruleUsedByTables = _record_ref_list_set('_grist_Views_section', 'rules')
def tableId(rec, table): def tableId(rec, table):
return rec.parentId.tableId return rec.parentId.tableId
@ -101,10 +102,16 @@ class MetaTableExtras(object):
def numRuleColUsers(rec, table): def numRuleColUsers(rec, table):
""" """
Returns the number of cols and fields using this col as a rule col Returns the number of cols and fields using this col as a rule
""" """
return len(rec.ruleUsedByCols) + len(rec.ruleUsedByFields) return len(rec.ruleUsedByCols) + len(rec.ruleUsedByFields)
def numRuleTableUsers(rec, table):
"""
Returns the number of tables using this col as a rule
"""
return len(rec.ruleUsedByTables)
def recalcOnChangesToSelf(rec, table): def recalcOnChangesToSelf(rec, table):
""" """
Whether the column is a trigger-formula column that depends on itself, used for Whether the column is a trigger-formula column that depends on itself, used for
@ -115,8 +122,11 @@ class MetaTableExtras(object):
def setAutoRemove(rec, table): def setAutoRemove(rec, table):
"""Marks the col for removal if it's a display/rule helper col with no more users.""" """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_display = rec.colId.startswith('gristHelper_Display') and rec.numDisplayColUsers == 0
as_rule = rec.colId.startswith('gristHelper_Conditional') and rec.numRuleColUsers == 0 as_col_rule = rec.colId.startswith('gristHelper_ConditionalRule') and rec.numRuleColUsers == 0
table.docmodel.setAutoRemove(rec, as_display or as_rule) as_row_rule = (
rec.colId.startswith('gristHelper_RowConditionalRule') and rec.numRuleTableUsers == 0
)
table.docmodel.setAutoRemove(rec, as_display or as_col_rule or as_row_rule)
class _grist_Views(object): class _grist_Views(object):

View File

@ -28,6 +28,11 @@ log = logger.Logger(__name__, logger.INFO)
# This should make it at least barely possible to share documents by people who are not all on the # This should make it at least barely possible to share documents by people who are not all on the
# same Grist version (even so, it will require more work). It should also make it somewhat safe to # same Grist version (even so, it will require more work). It should also make it somewhat safe to
# upgrade and then open the document with a previous version. # upgrade and then open the document with a previous version.
#
# After each migration you probably should run these commands:
# ./test/upgradeDocument public_samples/*.grist
# UPDATE_REGRESSION_DATA=1 GREP_TESTS=DocRegressionTests ./test/testrun.sh server
# ./test/upgradeDocument test/fixtures/docs/Hello.grist
all_migrations = {} all_migrations = {}
@ -1081,3 +1086,9 @@ def migration31(tdset):
actions.UpdateRecord('_grist_ACLResources', resource.id, {'tableId': new_name}) actions.UpdateRecord('_grist_ACLResources', resource.id, {'tableId': new_name})
) )
return tdset.apply_doc_actions(doc_actions) return tdset.apply_doc_actions(doc_actions)
@migration(schema_version=32)
def migration32(tdset):
return tdset.apply_doc_actions([
add_column('_grist_Views_section', 'rules', 'RefList:_grist_Tables_column'),
])

View File

@ -15,7 +15,7 @@ import six
import actions import actions
SCHEMA_VERSION = 31 SCHEMA_VERSION = 32
def make_column(col_id, col_type, formula='', isFormula=False): def make_column(col_id, col_type, formula='', isFormula=False):
return { return {
@ -194,6 +194,8 @@ def schema_create_actions():
make_column("linkTargetColRef", "Ref:_grist_Tables_column"), make_column("linkTargetColRef", "Ref:_grist_Tables_column"),
# embedId is deprecated as of version 12. Do not remove or reuse. # embedId is deprecated as of version 12. Do not remove or reuse.
make_column("embedId", "Text"), make_column("embedId", "Text"),
# Points to formula columns that hold conditional formatting rules for this view section.
make_column("rules", "RefList:_grist_Tables_column"),
]), ]),
# The fields of a view section. # The fields of a view section.
actions.AddTable("_grist_Views_section_field", [ actions.AddTable("_grist_Views_section_field", [

View File

@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
import test_engine
class TestGridRules(test_engine.EngineTestCase):
# Helper for rules action
def add_empty(self):
return self.apply_user_action(['AddEmptyRule', "Table1", 0, 0])
def set_rule(self, rule_index, formula):
rules = self.engine.docmodel.tables.lookupOne(tableId='Table1').rawViewSectionRef.rules
rule = list(rules)[rule_index]
return self.apply_user_action(['UpdateRecord', '_grist_Tables_column',
rule.id, {"formula": formula}])
def remove_rule(self, rule_index):
rules = self.engine.docmodel.tables.lookupOne(tableId='Table1').rawViewSectionRef.rules
rule = list(rules)[rule_index]
return self.apply_user_action(['RemoveColumn', 'Table1', rule.colId])
def test_simple_rules(self):
self.apply_user_action(['AddEmptyTable', None])
self.apply_user_action(['AddRecord', "Table1", None, {"A": 1}])
self.apply_user_action(['AddRecord', "Table1", None, {"A": 2}])
self.apply_user_action(['AddRecord', "Table1", None, {"A": 3}])
out_actions = self.add_empty()
self.assertPartialOutActions(out_actions, {"stored": [
["AddColumn", "Table1", "gristHelper_RowConditionalRule",
{"formula": "", "isFormula": True, "type": "Any"}],
["AddRecord", "_grist_Tables_column", 5,
{"colId": "gristHelper_RowConditionalRule", "formula": "", "isFormula": True,
"label": "gristHelper_RowConditionalRule", "parentId": 1, "parentPos": 5.0,
"type": "Any",
"widgetOptions": ""}],
["UpdateRecord", "_grist_Views_section", 2, {"rules": ["L", 5]}],
]})
out_actions = self.set_rule(0, "$A == 1")
self.assertPartialOutActions(out_actions, {"stored": [
["ModifyColumn", "Table1", "gristHelper_RowConditionalRule",
{"formula": "$A == 1"}],
["UpdateRecord", "_grist_Tables_column", 5, {"formula": "$A == 1"}],
["BulkUpdateRecord", "Table1", [1, 2, 3],
{"gristHelper_RowConditionalRule": [True, False, False]}],
]})
# Replace this rule with another rule to mark A = 2
out_actions = self.set_rule(0, "$A == 2")
self.assertPartialOutActions(out_actions, {"stored": [
["ModifyColumn", "Table1", "gristHelper_RowConditionalRule",
{"formula": "$A == 2"}],
["UpdateRecord", "_grist_Tables_column", 5, {"formula": "$A == 2"}],
["BulkUpdateRecord", "Table1", [1, 2],
{"gristHelper_RowConditionalRule": [False, True]}],
]})
# Add another rule A = 3
self.add_empty()
out_actions = self.set_rule(1, "$A == 3")
self.assertPartialOutActions(out_actions, {"stored": [
["ModifyColumn", "Table1", "gristHelper_RowConditionalRule2",
{"formula": "$A == 3"}],
["UpdateRecord", "_grist_Tables_column", 6, {"formula": "$A == 3"}],
["BulkUpdateRecord", "Table1", [1, 2, 3],
{"gristHelper_RowConditionalRule2": [False, False, True]}],
]})
# Remove the last rule
out_actions = self.remove_rule(1)
self.assertPartialOutActions(out_actions, {"stored": [
["RemoveRecord", "_grist_Tables_column", 6],
["UpdateRecord", "_grist_Views_section", 2, {"rules": ["L", 5]}],
["RemoveColumn", "Table1", "gristHelper_RowConditionalRule2"]
]})
# Remove last rule
out_actions = self.remove_rule(0)
self.assertPartialOutActions(out_actions, {"stored": [
["RemoveRecord", "_grist_Tables_column", 5],
["UpdateRecord", "_grist_Views_section", 2, {"rules": None}],
["RemoveColumn", "Table1", "gristHelper_RowConditionalRule"]
]})

View File

@ -200,6 +200,7 @@ def allowed_summary_change(key, updated, original):
allowed_to_change = {'widget', 'dateFormat', 'timeFormat', 'isCustomDateFormat', 'alignment', allowed_to_change = {'widget', 'dateFormat', 'timeFormat', 'isCustomDateFormat', 'alignment',
'fillColor', 'textColor', 'isCustomTimeFormat', 'isCustomDateFormat', 'fillColor', 'textColor', 'isCustomTimeFormat', 'isCustomDateFormat',
'numMode', 'numSign', 'decimals', 'maxDecimals', 'currency', 'numMode', 'numSign', 'decimals', 'maxDecimals', 'currency',
'fontBold', 'fontItalic', 'fontUnderline', 'fontStrikethrough',
'rulesOptions'} 'rulesOptions'}
# Helper function to remove protected keys from dictionary. # Helper function to remove protected keys from dictionary.
def trim(options): def trim(options):
@ -456,7 +457,7 @@ class UserActions(object):
table_id == "_grist_Views_section" table_id == "_grist_Views_section"
and any(rec.isRaw for i, rec in self._bulk_action_iter(table_id, row_ids)) and any(rec.isRaw for i, rec in self._bulk_action_iter(table_id, row_ids))
): ):
allowed_fields = {"title", "options", "sortColRefs"} allowed_fields = {"title", "options", "sortColRefs", "rules"}
has_summary_section = any(rec.tableRef.summarySourceTable has_summary_section = any(rec.tableRef.summarySourceTable
for i, rec in self._bulk_action_iter(table_id, row_ids)) for i, rec in self._bulk_action_iter(table_id, row_ids))
if has_summary_section: if has_summary_section:
@ -1637,23 +1638,26 @@ class UserActions(object):
Adds empty conditional style rule to a field or column. Adds empty conditional style rule to a field or column.
""" """
assert table_id, "table_id is required" 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" col_name = "gristHelper_ConditionalRule"
if field_ref: if field_ref:
field_or_col = self._docmodel.view_fields.table.get_record(field_ref) rule_owner = self._docmodel.view_fields.table.get_record(field_ref)
elif col_ref:
rule_owner = self._docmodel.columns.table.get_record(col_ref)
else: else:
field_or_col = self._docmodel.columns.table.get_record(col_ref) col_name = "gristHelper_RowConditionalRule"
rule_owner = self._docmodel.get_table_rec(table_id).rawViewSectionRef
col_info = self.AddHiddenColumn(table_id, 'gristHelper_ConditionalRule', { col_info = self.AddHiddenColumn(table_id, col_name, {
"type": "Any", "type": "Any",
"isFormula": True, "isFormula": True,
"formula": '' "formula": ''
}) })
new_rule = col_info['colRef'] new_rule = col_info['colRef']
existing_rules = field_or_col.rules._get_encodable_row_ids() if field_or_col.rules else [] existing_rules = rule_owner.rules._get_encodable_row_ids() if rule_owner.rules else []
updated_rules = existing_rules + [new_rule] updated_rules = existing_rules + [new_rule]
self._docmodel.update([field_or_col], rules=[encode_object(updated_rules)]) self._docmodel.update([rule_owner], rules=[encode_object(updated_rules)])
#---------------------------------------- #----------------------------------------

Binary file not shown.