(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
pull/13/head
Jarosław Sadziński 2 years ago
parent fbba6b8f52
commit 9e4d802405

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

@ -12,7 +12,7 @@ import {reportError} from 'app/client/models/errors';
import {KoSaveableObservable, ObjObservable, setSaveValue} from 'app/client/models/modelUtil';
import {SortedRowSet} from 'app/client/models/rowset';
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 {IconName} from 'app/client/ui2018/IconList';
import {squareCheckbox} from 'app/client/ui2018/checkbox';

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

@ -8,7 +8,7 @@
import * as AceEditor from 'app/client/components/AceEditor';
import {ColumnTransform} from 'app/client/components/ColumnTransform';
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 {testId} from 'app/client/ui2018/cssVars';
import {FieldBuilder} from 'app/client/widgets/FieldBuilder';

@ -1,26 +1,28 @@
/* globals alert, document, $ */
var _ = require('underscore');
var ko = require('knockout');
const _ = require('underscore');
const ko = require('knockout');
const debounce = require('lodash/debounce');
var gutil = require('app/common/gutil');
var BinaryIndexedTree = require('app/common/BinaryIndexedTree');
const gutil = require('app/common/gutil');
const BinaryIndexedTree = require('app/common/BinaryIndexedTree');
const {Sort} = require('app/common/SortSpec');
var dom = require('../lib/dom');
var kd = require('../lib/koDom');
var kf = require('../lib/koForm');
var koDomScrolly = require('../lib/koDomScrolly');
var tableUtil = require('../lib/tableUtil');
var {addToSort, sortBy} = require('../lib/sortUtil');
var commands = require('./commands');
var viewCommon = require('./viewCommon');
var Base = require('./Base');
var BaseView = require('./BaseView');
var selector = require('./Selector');
var {CopySelection} = require('./CopySelection');
const dom = require('../lib/dom');
const kd = require('../lib/koDom');
const kf = require('../lib/koForm');
const koDomScrolly = require('../lib/koDomScrolly');
const tableUtil = require('../lib/tableUtil');
const {addToSort, sortBy} = require('../lib/sortUtil');
const commands = require('./commands');
const viewCommon = require('./viewCommon');
const Base = require('./Base');
const BaseView = require('./BaseView');
const selector = require('./Selector');
const {CopySelection} = require('./CopySelection');
const koUtil = require('app/client/lib/koUtil');
const convert = require('color-convert');
const {renderAllRows} = require('app/client/components/Printing');
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 {showTooltip} = require('app/client/ui/tooltips');
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.
@ -1111,8 +1114,41 @@ GridView.prototype.buildDom = function() {
// 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.
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',
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
dom('div.gridview_data_row_num',
@ -1159,6 +1195,13 @@ GridView.prototype.buildDom = function() {
kd.toggleClass('record-add', row._isAddRow),
kd.style('borderLeftWidth', 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
kd.toggleClass('record-hlines', vHorizontalGridlines),
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.
class HoverColumnTooltip {
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;

@ -798,7 +798,8 @@ export class Importer extends DisposableWithEvents {
const editRow = vsi?.moveEditRowToCursor();
const editorHolder = openFormulaEditor({
gristDoc: this._gristDoc,
field,
column: field.column(),
editingFormula: field.editingFormula,
refElem,
editRow,
setupCleanup: this._setupFormulaEditorCleanup.bind(this),
@ -819,7 +820,7 @@ export class Importer extends DisposableWithEvents {
* focus.
*/
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);
@ -828,7 +829,7 @@ export class Importer extends DisposableWithEvents {
owner.onDispose(() => {
this.off('importer_focus', saveEdit);
field.editingFormula(false);
editingFormula(false);
});
}

@ -10,7 +10,7 @@ import {ColumnTransform} from 'app/client/components/ColumnTransform';
import {GristDoc} from 'app/client/components/GristDoc';
import * as TypeConversion from 'app/client/components/TypeConversion';
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 {testId} from 'app/client/ui2018/cssVars';
import {FieldBuilder} from 'app/client/widgets/FieldBuilder';

@ -14,7 +14,7 @@ const {addToSort} = require('app/client/lib/sortUtil');
const {updatePositions} = require('app/client/lib/sortUtil');
const {attachColumnFilterMenu} = require('app/client/ui/ColumnFilterMenu');
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 {labeledLeftSquareCheckbox} = require("app/client/ui2018/checkbox");
const {colors} = require('app/client/ui2018/cssVars');

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

@ -19,7 +19,8 @@
selected fields - this still remains white.
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 */
@ -27,7 +28,7 @@
}
.record.record-zebra.record-even {
background-color: #f8f8f8;
background-color: var(--grist-row-background-color-zebra, #f8f8f8);
}
.record.record-add {
@ -71,12 +72,12 @@
width: 100%;
height: 100%;
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));
color: var(--grist-actual-cell-color, black);
--grist-actual-cell-color: var(--grist-diff-color, var(--grist-cell-color, var(--grist-row-color)));
color: var(--grist-actual-cell-color, unset);
}
.field.selected .field_clip {
mix-blend-mode: darken;
mix-blend-mode: luminosity;
}
.field_clip.invalid, .field_clip.field-error-from-style {

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

@ -1,6 +1,7 @@
import {ColumnRec, DocModel, IRowModel, refListRecords, refRecord, ViewSectionRec} from 'app/client/models/DocModel';
import {formatterForRec} from 'app/client/models/entities/ColumnRec';
import * as modelUtil from 'app/client/models/modelUtil';
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
import {Style} from 'app/client/models/Styles';
import * as UserType from 'app/client/widgets/UserType';
import {DocumentSettings} from 'app/common/DocumentSettings';
@ -9,7 +10,7 @@ import {createParser} from 'app/common/ValueParser';
import * as ko from 'knockout';
// 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>;
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: ko.Computed<BaseFormatter>;
// Field can have a list of conditional styling rules. Each style is a combination of a formula and options
// that must by applied to a field. Style is persisted as a new hidden formula column and the list of such
// columns is stored as Reference List property ('rules') in a field or column.
// Rule for conditional style is a formula of the hidden column, style options are saved as JSON object in
// a styleOptions field (in that hidden formula column).
// If this field (or column) has a list of conditional styling rules.
hasRules: ko.Computed<boolean>;
// List of columns that are used as rules for conditional styles.
rulesCols: ko.Computed<ColumnRec[]>;
// List of columns ids that are used as rules for conditional styles.
rulesColsIds: ko.Computed<string[]>;
// List of styles used by conditional rules.
rulesStyles: modelUtil.KoSaveableObservable<Style[]>;
// Adds empty conditional style rule. Sets before sending to the server.
addEmptyRule(): Promise<void>;
// Removes one rule from the collection. Removes before sending update to the server.
removeRule(index: number): Promise<void>;
createValueParser(): (value: string) => any;
// Helper which adds/removes/updates field's displayCol to match the formula.
@ -253,6 +234,7 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
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.rulesColsIds = ko.pureComputed(() => this.rulesCols().map(c => c.colId()));
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()}`);
};
// Helper method to remove a rule.
this.removeRule = async (index: number) => {
const col = this.rulesCols.peek()[index];
if (!col) {
throw new Error(`There is no rule at index ${index}`);
}
const tableData = docModel.dataTables[col.table.peek().tableId.peek()].tableData;
const newStyles = this.rulesStyles.peek().slice();
if (newStyles.length >= index) {
newStyles.splice(index, 1);
} else {
console.debug(`There are not style options at index ${index}`);
}
const callback = () =>
Promise.all([
this.rulesStyles.setAndSave(newStyles),
tableData.sendTableAction(['RemoveColumn', col.colId.peek()])
]);
const actionOptions = {nestInActiveBundle: this.column.peek().isTransforming.peek()};
await docModel.docData.bundleActions("Remove conditional rule", callback, actionOptions);
};
this.removeRule = (index: number) => removeRule(docModel, this, index);
}

@ -8,6 +8,7 @@ import {
FilterRec,
IRowModel,
recordSet,
refListRecords,
refRecord,
TableRec,
ViewFieldRec,
@ -22,13 +23,14 @@ import {arrayRepeat} from 'app/common/gutil';
import {Sort} from 'app/common/SortSpec';
import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI';
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
import {Computed, Holder, Observable} from 'grainjs';
import * as ko from 'knockout';
import defaults = require('lodash/defaults');
// 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).
export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleOwner {
viewFields: ko.Computed<KoArray<ViewFieldRec>>;
// 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
selectedRows: Observable<number[]>;
editingFormula: ko.Computed<boolean>;
// Save all filters of fields/columns in the section.
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.
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 = {
verticalGridlines: true,
horizontalGridlines: true,
@ -586,4 +594,25 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this.allowSelectBy = Observable.create(this, false);
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);
}

@ -4,11 +4,12 @@ import * as kf from 'app/client/lib/koForm';
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
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 {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 {textInput} from 'app/client/ui2018/editableLabel';
import {IconName} from 'app/client/ui2018/IconList';
import {icon} from 'app/client/ui2018/icons';
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', `
display: flex;
cursor: pointer;
@ -548,3 +556,13 @@ const cssAddMapping = styled('div', `
--icon-color: ${colors.darkGreen};
}
`);
const cssTextInput = styled(textInput, `
flex: 1 0 auto;
&:disabled {
color: ${colors.slate};
background-color: ${colors.lightGrey};
pointer-events: none;
}
`);

@ -2,7 +2,7 @@ import {CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
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 {textButton} from 'app/client/ui2018/buttons';
import {colors, testId} from 'app/client/ui2018/cssVars';
@ -382,3 +382,7 @@ const cssColTieConnectors = styled('div', `
border-left: none;
z-index: -1;
`);
const cssEmptySeparator = styled('div', `
margin-top: 16px;
`);

@ -1,6 +1,6 @@
import { ViewSectionRec } from "app/client/models/DocModel";
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 { testId } from "app/client/ui2018/cssVars";
import { Computed, Disposable, dom, IDisposableOwner, styled } from "grainjs";

@ -335,6 +335,17 @@ export class RightPanel extends Disposable {
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', () => [
cssLabel('CHART TYPE'),
vct._buildChartConfigDom(),
@ -572,20 +583,14 @@ const cssBottomText = styled('span', `
padding: 4px 16px;
`);
export const cssLabel = styled('div', `
const cssLabel = styled('div', `
text-transform: uppercase;
margin: 16px 16px 12px 16px;
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;
margin: 8px 16px;
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-right: 0;
& > button {
@ -611,7 +611,7 @@ export const cssButtonRow = styled(cssRow, `
}
`);
export const cssIcon = styled(icon, `
const cssIcon = styled(icon, `
flex: 0 0 auto;
--icon-color: ${colors.slate};
`);
@ -669,7 +669,7 @@ const cssHoverIcon = styled(icon, `
background-color: vars(--icon-color);
`);
export const cssSubTabContainer = styled('div', `
const cssSubTabContainer = styled('div', `
height: 48px;
flex: none;
display: flex;
@ -677,7 +677,7 @@ export const cssSubTabContainer = styled('div', `
justify-content: space-between;
`);
export const cssSubTab = styled('div', `
const cssSubTab = styled('div', `
color: ${colors.lightGreen};
flex: auto;
height: 100%;
@ -709,15 +709,11 @@ const cssTabContents = styled('div', `
overflow: auto;
`);
export const cssSeparator = styled('div', `
const cssSeparator = styled('div', `
border-bottom: 1px solid ${colors.mediumGrey};
margin-top: 16px;
`);
export const cssEmptySeparator = styled('div', `
margin-top: 16px;
`);
const cssConfigContainer = styled('div', `
overflow: auto;
--color-list-item: none;
@ -765,7 +761,7 @@ const cssListItem = styled('li', `
padding: 4px 8px;
`);
export const cssTextInput = styled(textInput, `
const cssTextInput = styled(textInput, `
flex: 1 0 auto;
&:disabled {

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

@ -1,7 +1,7 @@
import type {ColumnRec} from 'app/client/models/entities/ColumnRec';
import type {TableRec} from 'app/client/models/entities/TableRec';
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 {basicButton, primaryButton} from "app/client/ui2018/buttons";
import {labeledSquareCheckbox} from "app/client/ui2018/checkbox";

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

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

@ -3,7 +3,7 @@ import {Computed, dom, fromKo, input, makeTestId, onElem, styled, TestId} from '
import * as commands from 'app/client/components/commands';
import {dragOverClass} from 'app/client/lib/dom';
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 {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
import {encodeQueryParams} from 'app/common/gutil';

@ -1,265 +1,88 @@
import {allCommands} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {ColumnRec} from 'app/client/models/DocModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {Style} from 'app/client/models/Styles';
import {cssFieldFormula} from 'app/client/ui/FieldConfig';
import {cssIcon, cssLabel, cssRow} from 'app/client/ui/RightPanel';
import {textButton} from 'app/client/ui2018/buttons';
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
import {colors} from 'app/client/ui2018/cssVars';
import {setupEditorCleanup} from 'app/client/widgets/FieldEditor';
import {cssError, openFormulaEditor} from 'app/client/widgets/FormulaEditor';
import {isRaisedException, isValidRuleValue} from 'app/common/gristTypes';
import {RowRecord} from 'app/plugin/GristData';
import {Computed, Disposable, dom, DomContents, fromKo, makeTestId, MultiHolder, Observable, styled} from 'grainjs';
import debounce = require('lodash/debounce');
const testId = makeTestId('test-widget-style-');
import {colors, vars} from 'app/client/ui2018/cssVars';
import {ConditionalStyle} from 'app/client/widgets/ConditionalStyle';
import {Disposable, dom, DomContents, fromKo, MultiHolder, Observable, styled} from 'grainjs';
export class CellStyle extends Disposable {
protected textColor: Observable<string|undefined>;
protected fillColor: Observable<string|undefined>;
protected fontBold: Observable<boolean|undefined>;
protected fontUnderline: Observable<boolean|undefined>;
protected fontItalic: Observable<boolean|undefined>;
protected 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);
private _textColor: Observable<string|undefined>;
private _fillColor: Observable<string|undefined>;
private _fontBold: Observable<boolean|undefined>;
private _fontUnderline: Observable<boolean|undefined>;
private _fontItalic: Observable<boolean|undefined>;
private _fontStrikethrough: Observable<boolean|undefined>;
constructor(
protected field: ViewFieldRec,
protected gristDoc: GristDoc,
protected defaultTextColor: string
private _field: ViewFieldRec,
private _gristDoc: GristDoc,
private _defaultTextColor: string
) {
super();
this.textColor = fromKo(this.field.textColor);
this.fillColor = fromKo(this.field.fillColor);
this.fontBold = fromKo(this.field.fontBold);
this.fontUnderline = fromKo(this.field.fontUnderline);
this.fontItalic = fromKo(this.field.fontItalic);
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;
});
this._textColor = fromKo(this._field.textColor);
this._fillColor = fromKo(this._field.fillColor);
this._fontBold = fromKo(this._field.fontBold);
this._fontUnderline = fromKo(this._field.fontUnderline);
this._fontItalic = fromKo(this._field.fontItalic);
this._fontStrikethrough = fromKo(this._field.fontStrikethrough);
}
public buildDom(): DomContents {
const holder = new MultiHolder();
return [
cssLabel('CELL STYLE', dom.autoDispose(holder)),
cssLine(
cssLabel('CELL STYLE', dom.autoDispose(holder)),
cssButton('Open row styles', dom.on('click', allCommands.viewTabOpen.run)),
),
cssRow(
colorSelect(
{
textColor: new ColorOption(this.textColor, false, this.defaultTextColor),
fillColor: new ColorOption(this.fillColor, true, '', 'none', '#FFFFFF'),
fontBold: this.fontBold,
fontItalic: this.fontItalic,
fontUnderline: this.fontUnderline,
fontStrikethrough: this.fontStrikethrough
textColor: new ColorOption(
{ color: this._textColor, defaultColor: this._defaultTextColor, noneText: 'default'}
),
fillColor: new ColorOption(
{ color: this._fillColor, allowsNone: true, noneText: 'none'}
),
fontBold: this._fontBold,
fontItalic: this._fontItalic,
fontUnderline: this._fontUnderline,
fontStrikethrough: this._fontStrikethrough
},
// Calling `field.widgetOptionsJson.save()` saves both fill and text color settings.
() => this.field.widgetOptionsJson.save()
() => this._field.widgetOptionsJson.save()
)
),
cssRow(
{style: 'margin-top: 16px'},
textButton(
'Add conditional style',
testId('add-conditional-style'),
dom.on('click', () => this.field.addEmptyRule())
),
dom.hide(this.field.hasRules)
),
dom.domComputedOwned(
use => use(this.field.rulesCols),
(owner, rules) =>
cssRuleList(
dom.show(rules.length > 0),
...rules.map((column, ruleIndex) => {
const textColor = this._buildStyleOption(owner, ruleIndex, 'textColor');
const fillColor = this._buildStyleOption(owner, ruleIndex, 'fillColor');
const 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)
),
dom.create(ConditionalStyle, "Cell Style", this._field, this._gristDoc)
];
}
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(
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', `
const cssLine = styled('div', `
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
margin-bottom: 12px;
`);
const cssErrorBorder = styled('div', `
border-color: ${colors.error};
margin: 16px 16px 12px 16px;
justify-content: space-between;
align-items: baseline;
`);
const cssRuleError = styled(cssError, `
margin: 2px 0px 10px 0px;
const cssLabel = styled('div', `
text-transform: uppercase;
font-size: ${vars.xsmallFontSize};
`);
const cssColumnsRow = styled(cssRow, `
align-items: flex-start;
margin-top: 0px;
margin-bottom: 0px;
const cssButton = styled(textButton, `
font-size: ${vars.mediumFontSize};
`);
const cssLeftColumn = styled('div', `
overflow: hidden;
flex: 1;
const cssRow = styled('div', `
display: flex;
flex-direction: column;
gap: 4px;
margin: 8px 16px;
align-items: center;
&-top-space {
margin-top: 24px;
}
&-disabled {
color: ${colors.slate};
}
`);

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

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

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

@ -1,5 +1,5 @@
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 {colorButton, ColorOption} from 'app/client/ui2018/ColorSelect';
import {colors, testId} from 'app/client/ui2018/cssVars';
@ -298,8 +298,9 @@ export class ChoiceListEntry extends Disposable {
dom.autoDispose(textColorObs),
dom.autoDispose(choiceText),
colorButton({
textColor: new ColorOption(textColorObs, false, '#000000'),
fillColor: new ColorOption(fillColorObs, true, '', 'none', '#FFFFFF'),
textColor: new ColorOption({color: textColorObs, defaultColor: '#000000'}),
fillColor: new ColorOption(
{color: fillColorObs, allowsNone: true, noneText: 'none', defaultColor: '#FFFFFF'}),
fontBold: fontBoldObs,
fontItalic: fontItalicObs,
fontUnderline: fontUnderlineObs,

@ -2,7 +2,7 @@ import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
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 {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry';
import {choiceToken, DEFAULT_FILL_COLOR, DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';

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

@ -8,7 +8,7 @@ var AbstractWidget = require('./AbstractWidget');
const {fromKoSave} = require('app/client/lib/fromKoSave');
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 {styled, fromKo} = require('grainjs');
const {select} = require('app/client/ui2018/menus');

@ -10,7 +10,7 @@ var gutil = require('app/common/gutil');
const {fromKoSave} = require('app/client/lib/fromKoSave');
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 {dom: gdom, styled, fromKo} = require('grainjs');
const {select} = require('app/client/ui2018/menus');

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

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

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

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

@ -2,7 +2,7 @@ import { fromKoSave } from 'app/client/lib/fromKoSave';
import { findLinks } from 'app/client/lib/textUtils';
import { DataRowModel } from 'app/client/models/DataRowModel';
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 { colors, testId } from 'app/client/ui2018/cssVars';
import { cssIconBackground, icon } from 'app/client/ui2018/icons';

@ -5,7 +5,7 @@ import {createGroup} from 'app/client/components/commands';
import {testId} from 'app/client/ui2018/cssVars';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
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 {undef} from 'app/common/gutil';
import {dom, Observable} from 'grainjs';
@ -19,13 +19,13 @@ export class NTextEditor extends NewBaseEditor {
protected commandGroup: any;
private _dom: HTMLElement;
private _editorPlacement: EditorPlacement;
private _editorPlacement!: EditorPlacement;
private _contentSizer: HTMLElement;
private _alignment: string;
// 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.
constructor(options: Options) {
constructor(protected options: FieldOptions) {
super(options);
const initialValue: string = undef(

@ -15,7 +15,6 @@ export interface IEditorCommandGroup {
export interface Options {
gristDoc: GristDoc;
field: ViewFieldRec;
cellValue: CellValue;
rowId: number;
formulaError?: Observable<CellValue>;
@ -26,6 +25,10 @@ export interface Options {
readonly: boolean;
}
export interface FieldOptions extends Options {
field: ViewFieldRec;
}
/**
* Required parameters:
* @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.
*/
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 {
return options ?
Disposable.create.call(this as any, ownerOrOptions, options) :

@ -3,7 +3,7 @@
*/
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
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 {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';

@ -1,6 +1,6 @@
import {DataRowModel} from 'app/client/models/DataRowModel';
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 {icon} from 'app/client/ui2018/icons';
import {IOptionFull, select} from 'app/client/ui2018/menus';

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

@ -8,7 +8,7 @@ import { menuCssClass } from 'app/client/ui2018/menus';
import { cssChoiceToken } from 'app/client/widgets/ChoiceToken';
import { createMobileButtons, getButtonMargins } from 'app/client/widgets/EditorButtons';
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 { ReferenceUtils } from 'app/client/lib/ReferenceUtils';
import { csvEncodeRow } from 'app/common/csvFormat';
@ -46,13 +46,13 @@ export class ReferenceListEditor extends NewBaseEditor {
private _tokenField: TokenField<ReferenceItem>;
private _textInput: HTMLInputElement;
private _dom: HTMLElement;
private _editorPlacement: EditorPlacement;
private _editorPlacement!: EditorPlacement;
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 _utils: ReferenceUtils;
constructor(options: Options) {
constructor(protected options: FieldOptions) {
super(options);
const docData = options.gristDoc.docData;

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

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

@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',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_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);
@ -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_Pages" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "indentation" INTEGER DEFAULT 0, "pagePos" NUMERIC DEFAULT 1e999);
CREATE TABLE IF NOT EXISTS "_grist_Views" (id INTEGER PRIMARY KEY, "name" TEXT DEFAULT '', "type" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Views_section" (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_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 '');
@ -42,7 +42,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',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);
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);
@ -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);
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','');
CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '');
INSERT INTO _grist_Views_section VALUES(1,1,1,'record','',100,1,'','','','','','[]',0,0,0,'');
INSERT INTO _grist_Views_section VALUES(2,1,0,'record','',100,1,'','','','','','',0,0,0,'');
CREATE TABLE IF NOT EXISTS "_grist_Views_section" (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,'',NULL);
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);
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);

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

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

@ -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
# 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.
#
# 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 = {}
@ -1081,3 +1086,9 @@ def migration31(tdset):
actions.UpdateRecord('_grist_ACLResources', resource.id, {'tableId': new_name})
)
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'),
])

@ -15,7 +15,7 @@ import six
import actions
SCHEMA_VERSION = 31
SCHEMA_VERSION = 32
def make_column(col_id, col_type, formula='', isFormula=False):
return {
@ -194,6 +194,8 @@ def schema_create_actions():
make_column("linkTargetColRef", "Ref:_grist_Tables_column"),
# embedId is deprecated as of version 12. Do not remove or reuse.
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.
actions.AddTable("_grist_Views_section_field", [

@ -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"]
]})

@ -200,6 +200,7 @@ def allowed_summary_change(key, updated, original):
allowed_to_change = {'widget', 'dateFormat', 'timeFormat', 'isCustomDateFormat', 'alignment',
'fillColor', 'textColor', 'isCustomTimeFormat', 'isCustomDateFormat',
'numMode', 'numSign', 'decimals', 'maxDecimals', 'currency',
'fontBold', 'fontItalic', 'fontUnderline', 'fontStrikethrough',
'rulesOptions'}
# Helper function to remove protected keys from dictionary.
def trim(options):
@ -456,7 +457,7 @@ class UserActions(object):
table_id == "_grist_Views_section"
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
for i, rec in self._bulk_action_iter(table_id, row_ids))
if has_summary_section:
@ -1637,23 +1638,26 @@ class UserActions(object):
Adds empty conditional style rule to a field or column.
"""
assert table_id, "table_id is required"
assert field_ref or col_ref, "field_ref or col_ref is required"
assert not field_ref or not col_ref, "can't set both field_ref and col_ref"
col_name = "gristHelper_ConditionalRule"
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:
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",
"isFormula": True,
"formula": ''
})
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]
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.
Loading…
Cancel
Save