mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Conditional formatting rules
Summary: Adding conditional formatting rules feature. Each column can have multiple styling rules which are applied in order when evaluated to a truthy value. - The creator panel has a new section: Cell Style - New user action AddEmptyRule for adding an empty rule - New columns in _grist_Table_columns and fields A new color picker will be introduced in a follow-up diff (as it is also used in choice/choice list/filters). Design document: https://grist.quip.com/FVzfAgoO5xOF/Conditional-Formatting-Implementation-Design Test Plan: new tests Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3282
This commit is contained in:
@@ -21,7 +21,7 @@ import {icon} from 'app/client/ui2018/icons';
|
||||
import {IOptionFull, linkSelect, menu, menuDivider, menuItem, multiSelect} from 'app/client/ui2018/menus';
|
||||
import {cssModalButtons, cssModalTitle} from 'app/client/ui2018/modals';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {openFormulaEditor} from 'app/client/widgets/FieldEditor';
|
||||
import {openFormulaEditor} from 'app/client/widgets/FormulaEditor';
|
||||
import {DataSourceTransformed, DestId, ImportResult, ImportTableResult, MergeOptions,
|
||||
MergeOptionsMap, MergeStrategy, NEW_TABLE, SKIP_TABLE,
|
||||
TransformColumn, TransformRule, TransformRuleMap} from 'app/common/ActiveDocAPI';
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
color: var(--grist-actual-cell-color, black);
|
||||
}
|
||||
|
||||
.field_clip.invalid {
|
||||
.field_clip.invalid, .field_clip.field-error-from-style {
|
||||
background-color: #ffb6c1;
|
||||
color: black;
|
||||
}
|
||||
|
||||
1
app/client/declarations.d.ts
vendored
1
app/client/declarations.d.ts
vendored
@@ -303,6 +303,7 @@ declare module "app/client/models/DataTableModel" {
|
||||
declare module "app/client/lib/koUtil" {
|
||||
export interface ComputedWithKoUtils<T> extends ko.Computed<T> {
|
||||
onlyNotifyUnequal(): this;
|
||||
previousOnUndefined(): this;
|
||||
}
|
||||
export interface ObservableWithKoUtils<T> extends ko.Observable<T> {
|
||||
assign(value: unknown): this;
|
||||
|
||||
@@ -63,6 +63,14 @@ ko.subscribable.fn.onlyNotifyUnequal = function() {
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Notifies only about distinct defined values. If the first value is undefined it will still be
|
||||
* returned.
|
||||
*/
|
||||
ko.subscribable.fn.previousOnUndefined = function() {
|
||||
this.equalityComparer = function(a, b) { return a === b || b === undefined; };
|
||||
return this;
|
||||
};
|
||||
|
||||
let _handlerFunc = (err) => {};
|
||||
let _origKoComputed = ko.computed;
|
||||
@@ -73,8 +81,8 @@ let _origKoComputed = ko.computed;
|
||||
* evaluates successfully to its previous value (or _handlerFunc may rethrow the error).
|
||||
*/
|
||||
function _wrapComputedRead(readFunc) {
|
||||
let lastValue;
|
||||
return function() {
|
||||
let lastValue;
|
||||
try {
|
||||
return (lastValue = readFunc.call(this));
|
||||
} catch (err) {
|
||||
|
||||
@@ -36,6 +36,8 @@ import {createValidationRec, ValidationRec} from 'app/client/models/entities/Val
|
||||
import {createViewFieldRec, ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {createViewRec, ViewRec} from 'app/client/models/entities/ViewRec';
|
||||
import {createViewSectionRec, ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
|
||||
import {GristObjCode} from 'app/plugin/GristData';
|
||||
import {decodeObject} from 'app/plugin/objtypes';
|
||||
|
||||
// Re-export all the entity types available. The recommended usage is like this:
|
||||
// import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
|
||||
@@ -95,6 +97,24 @@ export function refRecord<TRow extends MetaRowModel>(
|
||||
return ko.pureComputed(() => tableModel.getRowModel(rowIdObs() || 0, true));
|
||||
}
|
||||
|
||||
type RefListValue = [GristObjCode.List, ...number[]]|null;
|
||||
/**
|
||||
* Returns an observable with a list of records from another table, selected using RefList column.
|
||||
* @param {TableModel} tableModel: The model for the table to return a record from.
|
||||
* @param {ko.observable} rowsIdObs: An observable with a RefList value.
|
||||
*/
|
||||
export function refListRecords<TRow extends MetaRowModel>(
|
||||
tableModel: MetaTableModel<TRow>, rowsIdObs: ko.Observable<RefListValue>|ko.Computed<RefListValue>
|
||||
) {
|
||||
return ko.pureComputed(() => {
|
||||
const ids = decodeObject(rowsIdObs()) as number[]|null;
|
||||
if (!Array.isArray(ids)) {
|
||||
return [];
|
||||
}
|
||||
return ids.map(id => tableModel.getRowModel(id, true));
|
||||
});
|
||||
}
|
||||
|
||||
// Use an alias for brevity.
|
||||
type MTM<RowModel extends MetaRowModel> = MetaTableModel<RowModel>;
|
||||
|
||||
|
||||
19
app/client/models/Styles.ts
Normal file
19
app/client/models/Styles.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface Style {
|
||||
textColor?: string;
|
||||
fillColor?: string;
|
||||
}
|
||||
|
||||
export class CombinedStyle implements Style {
|
||||
public readonly textColor?: string;
|
||||
public readonly fillColor?: string;
|
||||
constructor(rules: Style[], flags: any[]) {
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
if (flags[i]) {
|
||||
const textColor = rules[i].textColor;
|
||||
const fillColor = rules[i].fillColor;
|
||||
this.textColor = textColor || this.textColor;
|
||||
this.fillColor = fillColor || this.fillColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {ColumnRec, DocModel, IRowModel, refRecord, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {ColumnRec, DocModel, IRowModel, refListRecords, refRecord, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {formatterForRec} from 'app/client/models/entities/ColumnRec';
|
||||
import * as modelUtil from 'app/client/models/modelUtil';
|
||||
import {Style} from 'app/client/models/Styles';
|
||||
import * as UserType from 'app/client/widgets/UserType';
|
||||
import {DocumentSettings} from 'app/common/DocumentSettings';
|
||||
import {BaseFormatter} from 'app/common/ValueFormatter';
|
||||
@@ -68,6 +69,9 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
|
||||
textColor: modelUtil.KoSaveableObservable<string|undefined>;
|
||||
fillColor: modelUtil.KoSaveableObservable<string>;
|
||||
|
||||
computedColor: ko.Computed<string|undefined>;
|
||||
computedFill: ko.Computed<string>;
|
||||
|
||||
documentSettings: ko.PureComputed<DocumentSettings>;
|
||||
|
||||
// Helper for Reference/ReferenceList columns, which returns a formatter according
|
||||
@@ -81,6 +85,26 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
|
||||
// `formatter` formats actual cell values, e.g. a whole list from the display column.
|
||||
formatter: ko.Computed<BaseFormatter>;
|
||||
|
||||
// Field can have a list of conditional styling rules. Each style is a combination of a formula and options
|
||||
// that must by applied to a field. Style is persisted as a new hidden formula column and the list of such
|
||||
// columns is stored as Reference List property ('rules') in a field or column.
|
||||
// Rule for conditional style is a formula of the hidden column, style options are saved as JSON object in
|
||||
// a styleOptions field (in that hidden formula column).
|
||||
|
||||
// If this field (or column) has a list of conditional styling rules.
|
||||
hasRules: ko.Computed<boolean>;
|
||||
// List of columns that are used as rules for conditional styles.
|
||||
rulesCols: ko.Computed<ColumnRec[]>;
|
||||
// List of columns ids that are used as rules for conditional styles.
|
||||
rulesColsIds: ko.Computed<string[]>;
|
||||
// List of styles used by conditional rules.
|
||||
rulesStyles: modelUtil.KoSaveableObservable<Style[]>;
|
||||
|
||||
// Adds empty conditional style rule. Sets before sending to the server.
|
||||
addEmptyRule(): Promise<void>;
|
||||
// Removes one rule from the collection. Removes before sending update to the server.
|
||||
removeRule(index: number): Promise<void>;
|
||||
|
||||
createValueParser(): (value: string) => any;
|
||||
|
||||
// Helper which adds/removes/updates field's displayCol to match the formula.
|
||||
@@ -211,7 +235,7 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
||||
// GridView, to avoid interfering with zebra stripes.
|
||||
this.fillColor = modelUtil.savingComputed({
|
||||
read: () => fillColorProp(),
|
||||
write: (setter, val) => setter(fillColorProp, val.toUpperCase() === '#FFFFFF' ? '' : val),
|
||||
write: (setter, val) => setter(fillColorProp, val?.toUpperCase() === '#FFFFFF' ? '' : (val ?? '')),
|
||||
});
|
||||
|
||||
this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson());
|
||||
@@ -231,4 +255,47 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
||||
};
|
||||
return docModel.docData.bundleActions("Update choices configuration", callback, actionOptions);
|
||||
};
|
||||
|
||||
this.rulesCols = refListRecords(docModel.columns, ko.pureComputed(() => this._fieldOrColumn().rules()));
|
||||
this.rulesColsIds = ko.pureComputed(() => this.rulesCols().map(c => c.colId()));
|
||||
this.rulesStyles = modelUtil.fieldWithDefault(
|
||||
this.widgetOptionsJson.prop("rulesOptions") as modelUtil.KoSaveableObservable<Style[]>,
|
||||
[]);
|
||||
this.hasRules = ko.pureComputed(() => this.rulesCols().length > 0);
|
||||
|
||||
// Helper method to add an empty rule (either initial or additional one).
|
||||
// Style options are added to widget options directly and can be briefly out of sync,
|
||||
// which is taken into account during rendering.
|
||||
this.addEmptyRule = async () => {
|
||||
const useCol = this.useColOptions.peek();
|
||||
const action = [
|
||||
'AddEmptyRule',
|
||||
this.column.peek().table.peek().tableId.peek(),
|
||||
useCol ? 0 : this.id.peek(), // field_ref
|
||||
useCol ? this.column.peek().id.peek() : 0, // col_ref
|
||||
];
|
||||
await docModel.docData.sendAction(action, `Update rules for ${this.colId.peek()}`);
|
||||
};
|
||||
|
||||
// Helper method to remove a rule.
|
||||
this.removeRule = async (index: number) => {
|
||||
const col = this.rulesCols.peek()[index];
|
||||
if (!col) {
|
||||
throw new Error(`There is no rule at index ${index}`);
|
||||
}
|
||||
const tableData = docModel.dataTables[col.table.peek().tableId.peek()].tableData;
|
||||
const newStyles = this.rulesStyles.peek().slice();
|
||||
if (newStyles.length >= index) {
|
||||
newStyles.splice(index, 1);
|
||||
} else {
|
||||
console.debug(`There are not style options at index ${index}`);
|
||||
}
|
||||
const callback = () =>
|
||||
Promise.all([
|
||||
this.rulesStyles.setAndSave(newStyles),
|
||||
tableData.sendTableAction(['RemoveColumn', col.colId.peek()])
|
||||
]);
|
||||
const actionOptions = {nestInActiveBundle: this.column.peek().isTransforming.peek()};
|
||||
await docModel.docData.bundleActions("Remove conditional rule", callback, actionOptions);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ import {textButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||
import {textInput} from 'app/client/ui2018/editableLabel';
|
||||
import {cssIconButton, icon} from 'app/client/ui2018/icons';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus';
|
||||
import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor';
|
||||
import {sanitizeIdent} from 'app/common/gutil';
|
||||
import {bundleChanges, Computed, dom, DomContents, DomElementArg, fromKo, MultiHolder,
|
||||
Observable, styled, subscribe} from 'grainjs';
|
||||
Observable, styled} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
import debounce = require('lodash/debounce');
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
|
||||
export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, cursor: ko.Computed<CursorPos>) {
|
||||
const untieColId = origColumn.untieColIdFromLabel;
|
||||
@@ -327,54 +327,7 @@ function buildFormula(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and return an observable for the count of errors in a column, which gets updated in
|
||||
* response to changes in origColumn and in user data.
|
||||
*/
|
||||
function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, origColumn: ColumnRec) {
|
||||
const errorMessage = Observable.create(owner, '');
|
||||
|
||||
// Count errors in origColumn when it's a formula column. Counts get cached by the
|
||||
// tableData.countErrors() method, and invalidated on relevant data changes.
|
||||
function countErrors() {
|
||||
if (owner.isDisposed()) { return; }
|
||||
const tableData = gristDoc.docData.getTable(origColumn.table.peek().tableId.peek());
|
||||
const isFormula = origColumn.isRealFormula.peek() || origColumn.hasTriggerFormula.peek();
|
||||
if (tableData && isFormula) {
|
||||
const colId = origColumn.colId.peek();
|
||||
const numCells = tableData.getColValues(colId)?.length || 0;
|
||||
const numErrors = tableData.countErrors(colId) || 0;
|
||||
errorMessage.set(
|
||||
(numErrors === 0) ? '' :
|
||||
(numCells === 1) ? `Error in the cell` :
|
||||
(numErrors === numCells) ? `Errors in all ${numErrors} cells` :
|
||||
`Errors in ${numErrors} of ${numCells} cells`
|
||||
);
|
||||
} else {
|
||||
errorMessage.set('');
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce the count calculation to defer it to the end of a bundle of actions.
|
||||
const debouncedCountErrors = debounce(countErrors, 0);
|
||||
|
||||
// If there is an update to the data in the table, count errors again. Since the same UI is
|
||||
// reused when different page widgets are selected, we need to re-create this subscription
|
||||
// whenever the selected table changes. We use a Computed to both react to changes and dispose
|
||||
// the previous subscription when it changes.
|
||||
Computed.create(owner, (use) => {
|
||||
const tableData = gristDoc.docData.getTable(use(use(origColumn.table).tableId));
|
||||
return tableData ? use.owner.autoDispose(tableData.tableActionEmitter.addListener(debouncedCountErrors)) : null;
|
||||
});
|
||||
|
||||
// The counts depend on the origColumn and its isRealFormula status, but with the debounced
|
||||
// callback and subscription to data, subscribe to relevant changes manually (rather than using
|
||||
// a Computed).
|
||||
owner.autoDispose(subscribe(use => { use(origColumn.id); use(origColumn.isRealFormula); debouncedCountErrors(); }));
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
const cssFieldFormula = styled(buildHighlightedCode, `
|
||||
export const cssFieldFormula = styled(buildHighlightedCode, `
|
||||
flex: auto;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
@@ -429,7 +382,3 @@ const cssColTieConnectors = styled('div', `
|
||||
border-left: none;
|
||||
z-index: -1;
|
||||
`);
|
||||
|
||||
const cssError = styled('div', `
|
||||
color: ${colors.error};
|
||||
`);
|
||||
|
||||
@@ -599,7 +599,7 @@ export const cssButtonRow = styled(cssRow, `
|
||||
|
||||
export const cssIcon = styled(icon, `
|
||||
flex: 0 0 auto;
|
||||
background-color: ${colors.slate};
|
||||
--icon-color: ${colors.slate};
|
||||
`);
|
||||
|
||||
const cssTopBarItem = styled('div', `
|
||||
|
||||
@@ -13,14 +13,14 @@ import { defaultMenuOptions, IOpenController, setPopupToCreateDom } from "popwea
|
||||
* native color picker. Pressing Escape reverts to the saved value. Caller is expected to handle
|
||||
* logging of onSave() callback rejection. In case of rejection, values are reverted to their saved one.
|
||||
*/
|
||||
export function colorSelect(textColor: Observable<string>, fillColor: Observable<string>,
|
||||
onSave: () => Promise<void>): Element {
|
||||
export function colorSelect(textColor: Observable<string|undefined>, fillColor: Observable<string|undefined>,
|
||||
onSave: () => Promise<void>, allowNone = false): Element {
|
||||
const selectBtn = cssSelectBtn(
|
||||
cssContent(
|
||||
cssButtonIcon(
|
||||
'T',
|
||||
dom.style('color', textColor),
|
||||
dom.style('background-color', (use) => use(fillColor).slice(0, 7)),
|
||||
dom.style('color', use => use(textColor) || ''),
|
||||
dom.style('background-color', (use) => use(fillColor)?.slice(0, 7) || ''),
|
||||
cssLightBorder.cls(''),
|
||||
testId('btn-icon'),
|
||||
),
|
||||
@@ -30,21 +30,21 @@ export function colorSelect(textColor: Observable<string>, fillColor: Observable
|
||||
testId('color-select'),
|
||||
);
|
||||
|
||||
const domCreator = (ctl: IOpenController) => buildColorPicker(ctl, textColor, fillColor, onSave);
|
||||
const domCreator = (ctl: IOpenController) => buildColorPicker(ctl, textColor, fillColor, onSave, allowNone);
|
||||
setPopupToCreateDom(selectBtn, domCreator, {...defaultMenuOptions, placement: 'bottom-end'});
|
||||
|
||||
return selectBtn;
|
||||
}
|
||||
|
||||
export function colorButton(textColor: Observable<string>, fillColor: Observable<string>,
|
||||
export function colorButton(textColor: Observable<string|undefined>, fillColor: Observable<string|undefined>,
|
||||
onSave: () => Promise<void>): Element {
|
||||
const iconBtn = cssIconBtn(
|
||||
icon(
|
||||
'Dropdown',
|
||||
dom.style('background-color', textColor),
|
||||
dom.style('background-color', use => use(textColor) || ''),
|
||||
testId('color-button-dropdown')
|
||||
),
|
||||
dom.style('background-color', (use) => use(fillColor).slice(0, 7)),
|
||||
dom.style('background-color', (use) => use(fillColor)?.slice(0, 7) || ''),
|
||||
dom.on('click', (e) => { e.stopPropagation(); e.preventDefault(); }),
|
||||
testId('color-button'),
|
||||
);
|
||||
@@ -55,8 +55,10 @@ export function colorButton(textColor: Observable<string>, fillColor: Observable
|
||||
return iconBtn;
|
||||
}
|
||||
|
||||
function buildColorPicker(ctl: IOpenController, textColor: Observable<string>, fillColor: Observable<string>,
|
||||
onSave: () => Promise<void>): Element {
|
||||
function buildColorPicker(ctl: IOpenController, textColor: Observable<string|undefined>,
|
||||
fillColor: Observable<string|undefined>,
|
||||
onSave: () => Promise<void>,
|
||||
allowNone = false): Element {
|
||||
const textColorModel = PickerModel.create(null, textColor);
|
||||
const fillColorModel = PickerModel.create(null, fillColor);
|
||||
|
||||
@@ -83,8 +85,8 @@ function buildColorPicker(ctl: IOpenController, textColor: Observable<string>, f
|
||||
|
||||
const colorSquare = (...args: DomArg[]) => cssColorSquare(
|
||||
...args,
|
||||
dom.style('color', textColor),
|
||||
dom.style('background-color', fillColor),
|
||||
dom.style('color', use => use(textColor) || ''),
|
||||
dom.style('background-color', use => use(fillColor) || ''),
|
||||
cssLightBorder.cls(''),
|
||||
);
|
||||
|
||||
@@ -92,13 +94,15 @@ function buildColorPicker(ctl: IOpenController, textColor: Observable<string>, f
|
||||
dom.create(PickerComponent, fillColorModel, {
|
||||
colorSquare: colorSquare(),
|
||||
title: 'fill',
|
||||
defaultMode: 'lighter'
|
||||
defaultMode: 'lighter',
|
||||
allowNone
|
||||
}),
|
||||
cssVSpacer(),
|
||||
dom.create(PickerComponent, textColorModel, {
|
||||
colorSquare: colorSquare('T'),
|
||||
title: 'text',
|
||||
defaultMode: 'darker'
|
||||
defaultMode: 'darker',
|
||||
allowNone
|
||||
}),
|
||||
|
||||
// gives focus and binds keydown events
|
||||
@@ -118,6 +122,7 @@ interface PickerComponentOptions {
|
||||
colorSquare: Element;
|
||||
title: string;
|
||||
defaultMode: 'darker'|'lighter';
|
||||
allowNone?: boolean;
|
||||
}
|
||||
|
||||
// PickerModel is a helper model that helps keep track of the server value for an observable that
|
||||
@@ -127,7 +132,7 @@ interface PickerComponentOptions {
|
||||
class PickerModel extends Disposable {
|
||||
private _serverValue = this.obs.get();
|
||||
private _localChange: boolean = false;
|
||||
constructor(public obs: Observable<string>) {
|
||||
constructor(public obs: Observable<string|undefined>) {
|
||||
super();
|
||||
this.autoDispose(this.obs.addListener((val) => {
|
||||
if (this._localChange) { return; }
|
||||
@@ -136,7 +141,7 @@ class PickerModel extends Disposable {
|
||||
}
|
||||
|
||||
// Set the value picked by the user
|
||||
public setValue(val: string) {
|
||||
public setValue(val: string|undefined) {
|
||||
this._localChange = true;
|
||||
this.obs.set(val);
|
||||
this._localChange = false;
|
||||
@@ -155,7 +160,7 @@ class PickerModel extends Disposable {
|
||||
|
||||
class PickerComponent extends Disposable {
|
||||
|
||||
private _color = Computed.create(this, this._model.obs, (use, val) => val.toUpperCase().slice(0, 7));
|
||||
private _color = Computed.create(this, this._model.obs, (use, val) => (val || '').toUpperCase().slice(0, 7));
|
||||
private _mode = Observable.create<'darker'|'lighter'>(this, this._guessMode());
|
||||
|
||||
constructor(private _model: PickerModel, private _options: PickerComponentOptions) {
|
||||
@@ -172,13 +177,17 @@ class PickerComponent extends Disposable {
|
||||
cssColorInput(
|
||||
{type: 'color'},
|
||||
dom.attr('value', this._color),
|
||||
dom.on('input', (ev, elem) => this._setValue(elem.value)),
|
||||
dom.on('input', (ev, elem) => this._setValue(elem.value || undefined)),
|
||||
testId(`${title}-input`),
|
||||
),
|
||||
),
|
||||
cssHexBox(
|
||||
this._color,
|
||||
async (val) => { if (isValidHex(val)) { this._model.setValue(val); } },
|
||||
async (val) => {
|
||||
if ((this._options.allowNone && !val) || isValidHex(val)) {
|
||||
this._model.setValue(val);
|
||||
}
|
||||
},
|
||||
testId(`${title}-hex`),
|
||||
// select the hex value on click. Doing it using settimeout allows to avoid some
|
||||
// sporadically losing the selection just after the click.
|
||||
@@ -207,7 +216,15 @@ class PickerComponent extends Disposable {
|
||||
cssLightBorder.cls('', (use) => use(this._mode) === 'lighter'),
|
||||
cssColorSquare.cls('-selected', (use) => use(this._color) === color),
|
||||
dom.style('outline-color', (use) => use(this._mode) === 'lighter' ? '' : color),
|
||||
dom.on('click', () => this._setValue(color)),
|
||||
dom.on('click', () => {
|
||||
// Clicking same color twice - removes the selection.
|
||||
if (this._model.obs.get() === color &&
|
||||
this._options.allowNone) {
|
||||
this._setValue(undefined);
|
||||
} else {
|
||||
this._setValue(color);
|
||||
}
|
||||
}),
|
||||
testId(`color-${color}`),
|
||||
)
|
||||
))),
|
||||
@@ -216,7 +233,7 @@ class PickerComponent extends Disposable {
|
||||
];
|
||||
}
|
||||
|
||||
private _setValue(val: string) {
|
||||
private _setValue(val: string|undefined) {
|
||||
this._model.setValue(val);
|
||||
}
|
||||
|
||||
|
||||
239
app/client/widgets/CellStyle.ts
Normal file
239
app/client/widgets/CellStyle.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ColumnRec} from 'app/client/models/DocModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
||||
import {Style} from 'app/client/models/Styles';
|
||||
import {cssFieldFormula} from 'app/client/ui/FieldConfig';
|
||||
import {cssIcon, cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {textButton} from 'app/client/ui2018/buttons';
|
||||
import {colorSelect} from 'app/client/ui2018/ColorSelect';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {setupEditorCleanup} from 'app/client/widgets/FieldEditor';
|
||||
import {cssError, openFormulaEditor} from 'app/client/widgets/FormulaEditor';
|
||||
import {isRaisedException, isValidRuleValue} from 'app/common/gristTypes';
|
||||
import {RowRecord} from 'app/plugin/GristData';
|
||||
import {Computed, Disposable, dom, DomContents, fromKo, makeTestId, MultiHolder, Observable, styled} from 'grainjs';
|
||||
import debounce = require('lodash/debounce');
|
||||
|
||||
const testId = makeTestId('test-widget-style-');
|
||||
|
||||
export class CellStyle extends Disposable {
|
||||
protected textColor: Observable<string>;
|
||||
protected fillColor: Observable<string>;
|
||||
// Holds data from currently selected record (holds data only when this field has conditional styles).
|
||||
protected currentRecord: Computed<RowRecord | undefined>;
|
||||
// Helper field for refreshing current record data.
|
||||
protected dataChangeTrigger = Observable.create(this, 0);
|
||||
|
||||
constructor(
|
||||
protected field: ViewFieldRec,
|
||||
protected gristDoc: GristDoc,
|
||||
defaultTextColor: string = '#000000'
|
||||
) {
|
||||
super();
|
||||
this.textColor = Computed.create(
|
||||
this,
|
||||
use => use(this.field.textColor) || defaultTextColor
|
||||
).onWrite(val => this.field.textColor(val === defaultTextColor ? '' : val));
|
||||
this.fillColor = fromKo(this.field.fillColor);
|
||||
this.currentRecord = Computed.create(this, use => {
|
||||
if (!use(this.field.hasRules)) {
|
||||
return;
|
||||
}
|
||||
// As we are not subscribing to data change, we will monitor actions
|
||||
// that are sent from the server to refresh this computed observable.
|
||||
void use(this.dataChangeTrigger);
|
||||
const tableId = use(use(use(field.column).table).tableId);
|
||||
const tableData = gristDoc.docData.getTable(tableId)!;
|
||||
const cursor = use(gristDoc.cursorPosition);
|
||||
// Make sure we are not on the new row.
|
||||
if (!cursor || typeof cursor.rowId !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
return tableData.getRecord(cursor.rowId);
|
||||
});
|
||||
|
||||
// Here we will subscribe to tableActionEmitter, and update currentRecord observable.
|
||||
// We have 'dataChangeTrigger' that is just a number that will be updated every time
|
||||
// we received some table actions.
|
||||
const debouncedUpdate = debounce(() => {
|
||||
if (this.dataChangeTrigger.isDisposed()) {
|
||||
return;
|
||||
}
|
||||
this.dataChangeTrigger.set(this.dataChangeTrigger.get() + 1);
|
||||
}, 0);
|
||||
Computed.create(this, (use) => {
|
||||
const tableId = use(use(use(field.column).table).tableId);
|
||||
const tableData = gristDoc.docData.getTable(tableId);
|
||||
return tableData ? use.owner.autoDispose(tableData.tableActionEmitter.addListener(debouncedUpdate)) : null;
|
||||
});
|
||||
}
|
||||
|
||||
public buildDom(): DomContents {
|
||||
const holder = new MultiHolder();
|
||||
return [
|
||||
cssLabel('CELL STYLE', dom.autoDispose(holder)),
|
||||
cssRow(
|
||||
colorSelect(
|
||||
this.textColor,
|
||||
this.fillColor,
|
||||
// Calling `field.widgetOptionsJson.save()` saves both fill and text color settings.
|
||||
() => this.field.widgetOptionsJson.save()
|
||||
)
|
||||
),
|
||||
cssRow(
|
||||
{style: 'margin-top: 16px'},
|
||||
textButton(
|
||||
'Add conditional style',
|
||||
testId('add-conditional-style'),
|
||||
dom.on('click', () => this.field.addEmptyRule())
|
||||
),
|
||||
dom.hide(this.field.hasRules)
|
||||
),
|
||||
dom.domComputedOwned(
|
||||
use => use(this.field.rulesCols),
|
||||
(owner, rules) =>
|
||||
cssRuleList(
|
||||
dom.show(rules.length > 0),
|
||||
...rules.map((column, ruleIndex) => {
|
||||
const textColor = this._buildStyleOption(owner, ruleIndex, 'textColor');
|
||||
const fillColor = this._buildStyleOption(owner, ruleIndex, 'fillColor');
|
||||
const save = async () => {
|
||||
// This will save both options.
|
||||
await this.field.rulesStyles.save();
|
||||
};
|
||||
const currentValue = Computed.create(owner, use => {
|
||||
const record = use(this.currentRecord);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
const value = record[use(column.colId)];
|
||||
return value;
|
||||
});
|
||||
const hasError = Computed.create(owner, use => {
|
||||
return !isValidRuleValue(use(currentValue));
|
||||
});
|
||||
const errorMessage = Computed.create(owner, use => {
|
||||
const value = use(currentValue);
|
||||
return (!use(hasError) ? '' :
|
||||
isRaisedException(value) ? 'Error in style rule' :
|
||||
'Rule must return True or False');
|
||||
});
|
||||
return dom('div',
|
||||
testId(`conditional-rule-${ruleIndex}`),
|
||||
testId(`conditional-rule`), // for testing
|
||||
cssLineLabel('IF...'),
|
||||
cssColumnsRow(
|
||||
cssLeftColumn(
|
||||
this._buildRuleFormula(column.formula, column, hasError),
|
||||
cssRuleError(
|
||||
dom.text(errorMessage),
|
||||
dom.show(hasError),
|
||||
testId(`rule-error-${ruleIndex}`),
|
||||
),
|
||||
colorSelect(textColor, fillColor, save, true)
|
||||
),
|
||||
cssRemoveButton(
|
||||
'Remove',
|
||||
testId(`remove-rule-${ruleIndex}`),
|
||||
dom.on('click', () => this.field.removeRule(ruleIndex))
|
||||
)
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
),
|
||||
cssRow(
|
||||
textButton('Add another rule'),
|
||||
testId('add-another-rule'),
|
||||
dom.on('click', () => this.field.addEmptyRule()),
|
||||
dom.show(this.field.hasRules)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _buildStyleOption(owner: Disposable, index: number, option: keyof Style) {
|
||||
const obs = Computed.create(owner, use => use(this.field.rulesStyles)[index]?.[option]);
|
||||
obs.onWrite(value => {
|
||||
const list = Array.from(this.field.rulesStyles.peek() ?? []);
|
||||
list[index] = list[index] ?? {};
|
||||
list[index][option] = value;
|
||||
this.field.rulesStyles(list);
|
||||
});
|
||||
return obs;
|
||||
}
|
||||
|
||||
private _buildRuleFormula(
|
||||
formula: KoSaveableObservable<string>,
|
||||
column: ColumnRec,
|
||||
hasError: Observable<boolean>
|
||||
) {
|
||||
return cssFieldFormula(
|
||||
formula,
|
||||
{maxLines: 1},
|
||||
dom.cls('formula_field_sidepane'),
|
||||
dom.cls(cssErrorBorder.className, hasError),
|
||||
{tabIndex: '-1'},
|
||||
dom.on('focus', (_, refElem) => {
|
||||
const vsi = this.gristDoc.viewModel.activeSection().viewInstance();
|
||||
const editorHolder = openFormulaEditor({
|
||||
gristDoc: this.gristDoc,
|
||||
field: this.field,
|
||||
column,
|
||||
editRow: vsi?.moveEditRowToCursor(),
|
||||
refElem,
|
||||
setupCleanup: setupEditorCleanup,
|
||||
});
|
||||
// Add editor to document holder - this will prevent multiple formula editor instances.
|
||||
this.gristDoc.fieldEditorHolder.autoDispose(editorHolder);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cssRemoveButton = styled(cssIcon, `
|
||||
flex: none;
|
||||
margin: 6px;
|
||||
margin-right: 0px;
|
||||
transform: translateY(4px);
|
||||
cursor: pointer;
|
||||
--icon-color: ${colors.slate};
|
||||
&:hover {
|
||||
--icon-color: ${colors.lightGreen};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssLineLabel = styled(cssLabel, `
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
`);
|
||||
|
||||
const cssRuleList = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 12px;
|
||||
`);
|
||||
|
||||
const cssErrorBorder = styled('div', `
|
||||
border-color: ${colors.error};
|
||||
`);
|
||||
|
||||
const cssRuleError = styled(cssError, `
|
||||
margin: 2px 0px 10px 0px;
|
||||
`);
|
||||
|
||||
const cssColumnsRow = styled(cssRow, `
|
||||
align-items: flex-start;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
`);
|
||||
|
||||
const cssLeftColumn = styled('div', `
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`);
|
||||
@@ -13,13 +13,16 @@ import { reportError } from 'app/client/models/AppModel';
|
||||
import { DataRowModel } from 'app/client/models/DataRowModel';
|
||||
import { ColumnRec, DocModel, ViewFieldRec } from 'app/client/models/DocModel';
|
||||
import { SaveableObjObservable, setSaveValue } from 'app/client/models/modelUtil';
|
||||
import { CombinedStyle, Style } from 'app/client/models/Styles';
|
||||
import { FieldSettingsMenu } from 'app/client/ui/FieldMenus';
|
||||
import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanel';
|
||||
import { buttonSelect } from 'app/client/ui2018/buttonSelect';
|
||||
import { colors } from 'app/client/ui2018/cssVars';
|
||||
import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
|
||||
import { DiffBox } from 'app/client/widgets/DiffBox';
|
||||
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
|
||||
import { FieldEditor, openFormulaEditor, saveWithoutEditor, setupEditorCleanup } from 'app/client/widgets/FieldEditor';
|
||||
import { FieldEditor, saveWithoutEditor, setupEditorCleanup } from 'app/client/widgets/FieldEditor';
|
||||
import { openFormulaEditor } from 'app/client/widgets/FormulaEditor';
|
||||
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
|
||||
import { NewBaseEditor } from "app/client/widgets/NewBaseEditor";
|
||||
import * as UserType from 'app/client/widgets/UserType';
|
||||
@@ -337,7 +340,8 @@ export class FieldBuilder extends Disposable {
|
||||
kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
|
||||
dom('div',
|
||||
widget.buildConfigDom(),
|
||||
widget.buildColorConfigDom(),
|
||||
cssSeparator(),
|
||||
widget.buildColorConfigDom(this.gristDoc),
|
||||
|
||||
// If there is more than one field for this column (i.e. present in multiple views).
|
||||
kd.maybe(() => this.origColumn.viewFields().all().length > 1, () =>
|
||||
@@ -414,6 +418,35 @@ export class FieldBuilder extends Disposable {
|
||||
* buildEditorDom functions of its widgetImpl.
|
||||
*/
|
||||
public buildDomWithCursor(row: DataRowModel, isActive: boolean, isSelected: boolean) {
|
||||
const computedFlags = koUtil.withKoUtils(ko.pureComputed(() => {
|
||||
return this.field.rulesColsIds().map(colRef => row.cells[colRef]?.() ?? false);
|
||||
}, this).extend({ deferred: true }));
|
||||
// Here we are using computedWithPrevious helper, to return
|
||||
// the previous value of computed rule. When user adds or deletes
|
||||
// rules there is a brief moment that rule is still not evaluated
|
||||
// (rules.length != value.length), in this case return last value
|
||||
// and wait for the update.
|
||||
const computedRule = koUtil.withKoUtils(ko.pureComputed(() => {
|
||||
if (this.isDisposed()) { return null; }
|
||||
const styles: Style[] = this.field.rulesStyles();
|
||||
// Make sure that rules where computed.
|
||||
if (!Array.isArray(styles) || styles.length === 0) { return null; }
|
||||
const flags = computedFlags();
|
||||
// Make extra sure that all rules are up to date.
|
||||
// If not, fallback to the previous value.
|
||||
// We need to make sure that all rules columns are created,
|
||||
// sometimes there are more styles for a brief moment.
|
||||
if (styles.length < flags.length) { return/* undefined */; }
|
||||
// We will combine error information in the same computed value.
|
||||
// If there is an error in rules - return it instead of the style.
|
||||
const error = flags.some(f => !gristTypes.isValidRuleValue(f));
|
||||
if (error) {
|
||||
return { error };
|
||||
}
|
||||
// Combine them into a single style option.
|
||||
return { style : new CombinedStyle(styles, flags) };
|
||||
}, this).extend({ deferred: true })).previousOnUndefined();
|
||||
|
||||
const widgetObs = koUtil.withKoUtils(ko.computed(function() {
|
||||
// TODO: Accessing row values like this doesn't always work (row and field might not be updated
|
||||
// simultaneously).
|
||||
@@ -429,11 +462,29 @@ export class FieldBuilder extends Disposable {
|
||||
}
|
||||
}, this).extend({ deferred: true })).onlyNotifyUnequal();
|
||||
|
||||
const textColor = koUtil.withKoUtils(ko.computed(function() {
|
||||
if (this.isDisposed()) { return null; }
|
||||
const fromRules = computedRule()?.style?.textColor;
|
||||
return fromRules || this.field.textColor() || '';
|
||||
}, this)).onlyNotifyUnequal();
|
||||
|
||||
const background = koUtil.withKoUtils(ko.computed(function() {
|
||||
if (this.isDisposed()) { return null; }
|
||||
const fromRules = computedRule()?.style?.fillColor;
|
||||
return fromRules || this.field.fillColor();
|
||||
}, this)).onlyNotifyUnequal();
|
||||
|
||||
const errorInStyle = ko.pureComputed(() => Boolean(computedRule()?.error));
|
||||
|
||||
return (elem: Element) => {
|
||||
this._rowMap.set(row, elem);
|
||||
dom(elem,
|
||||
dom.autoDispose(widgetObs),
|
||||
dom.autoDispose(computedFlags),
|
||||
dom.autoDispose(errorInStyle),
|
||||
dom.autoDispose(textColor),
|
||||
dom.autoDispose(computedRule),
|
||||
dom.autoDispose(background),
|
||||
this._options.isPreview ? null : kd.cssClass(this.field.formulaCssClass),
|
||||
kd.toggleClass("readonly", toKo(ko, this._readonly)),
|
||||
kd.maybe(isSelected, () => dom('div.selected_cursor',
|
||||
@@ -443,8 +494,9 @@ export class FieldBuilder extends Disposable {
|
||||
if (this.isDisposed()) { return null; } // Work around JS errors during field removal.
|
||||
const cellDom = widget ? widget.buildDom(row) : buildErrorDom(row, this.field);
|
||||
return dom(cellDom, kd.toggleClass('has_cursor', isActive),
|
||||
kd.style('--grist-cell-color', () => this.field.textColor() || ''),
|
||||
kd.style('--grist-cell-background-color', this.field.fillColor));
|
||||
kd.toggleClass('field-error-from-style', errorInStyle),
|
||||
kd.style('--grist-cell-color', textColor),
|
||||
kd.style('--grist-cell-background-color', background));
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -547,3 +599,8 @@ export class FieldBuilder extends Disposable {
|
||||
const cssTypeSelectMenu = styled('div', `
|
||||
max-height: 500px;
|
||||
`);
|
||||
|
||||
const cssSeparator = styled('div', `
|
||||
border-bottom: 1px solid ${colors.mediumGrey};
|
||||
margin-top: 16px;
|
||||
`);
|
||||
|
||||
@@ -3,17 +3,15 @@ import {Cursor} from 'app/client/components/Cursor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {showTooltipToCreateFormula} from 'app/client/widgets/EditorTooltip';
|
||||
import {FormulaEditor} from 'app/client/widgets/FormulaEditor';
|
||||
import {FormulaEditor, getFormulaError} from 'app/client/widgets/FormulaEditor';
|
||||
import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
|
||||
import {asyncOnce} from "app/common/AsyncCreate";
|
||||
import {CellValue} from "app/common/DocActions";
|
||||
import {isRaisedException} from 'app/common/gristTypes';
|
||||
import * as gutil from 'app/common/gutil';
|
||||
import {Disposable, Emitter, Holder, MultiHolder, Observable} from 'grainjs';
|
||||
import {Disposable, Emitter, Holder, MultiHolder} from 'grainjs';
|
||||
import isEqual = require('lodash/isEqual');
|
||||
import {CellPosition} from "app/client/components/CellPosition";
|
||||
|
||||
@@ -372,73 +370,6 @@ export class FieldEditor extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a formula editor. Returns a Disposable that owns the editor.
|
||||
*/
|
||||
export function openFormulaEditor(options: {
|
||||
gristDoc: GristDoc,
|
||||
field: ViewFieldRec,
|
||||
// Needed to get exception value, if any.
|
||||
editRow?: DataRowModel,
|
||||
// Element over which to position the editor.
|
||||
refElem: Element,
|
||||
editValue?: string,
|
||||
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
||||
onCancel?: () => void,
|
||||
// Called after editor is created to set up editor cleanup (e.g. saving on click-away).
|
||||
setupCleanup: (
|
||||
owner: MultiHolder,
|
||||
doc: GristDoc,
|
||||
field: ViewFieldRec,
|
||||
save: () => Promise<void>
|
||||
) => void,
|
||||
}): Disposable {
|
||||
const {gristDoc, field, editRow, refElem, setupCleanup} = options;
|
||||
const holder = MultiHolder.create(null);
|
||||
const column = field.column();
|
||||
|
||||
// AsyncOnce ensures it's called once even if triggered multiple times.
|
||||
const saveEdit = asyncOnce(async () => {
|
||||
const formula = editor.getCellValue();
|
||||
if (options.onSave) {
|
||||
await options.onSave(column, formula as string);
|
||||
} else if (formula !== column.formula.peek()) {
|
||||
await column.updateColValues({formula});
|
||||
}
|
||||
holder.dispose();
|
||||
});
|
||||
|
||||
// These are the commands for while the editor is active.
|
||||
const editCommands = {
|
||||
fieldEditSave: () => { saveEdit().catch(reportError); },
|
||||
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
|
||||
fieldEditCancel: () => { holder.dispose(); options.onCancel?.(); },
|
||||
};
|
||||
|
||||
// Replace the item in the Holder with a new one, disposing the previous one.
|
||||
const editor = FormulaEditor.create(holder, {
|
||||
gristDoc,
|
||||
field,
|
||||
cellValue: column.formula(),
|
||||
formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
|
||||
editValue: options.editValue,
|
||||
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
|
||||
commands: editCommands,
|
||||
cssClass: 'formula_editor_sidepane',
|
||||
readonly : false
|
||||
});
|
||||
editor.attach(refElem);
|
||||
|
||||
// When formula is empty enter formula-editing mode (highlight formula icons; click on a column inserts its ID).
|
||||
// This function is used for primarily for switching between different column behaviors, so we want to enter full
|
||||
// edit mode right away.
|
||||
// TODO: consider converting it to parameter, when this will be used in different scenarios.
|
||||
if (!column.formula()) {
|
||||
field.editingFormula(true);
|
||||
}
|
||||
setupCleanup(holder, gristDoc, field, saveEdit);
|
||||
return holder;
|
||||
}
|
||||
|
||||
/**
|
||||
* For an readonly editor, set up its cleanup:
|
||||
@@ -479,25 +410,3 @@ export function setupEditorCleanup(
|
||||
field.editingFormula(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If the cell at the given row and column is a formula value containing an exception, return an
|
||||
* observable with this exception, and fetch more details to add to the observable.
|
||||
*/
|
||||
function getFormulaError(
|
||||
gristDoc: GristDoc, editRow: DataRowModel, column: ColumnRec
|
||||
): Observable<CellValue>|undefined {
|
||||
const colId = column.colId.peek();
|
||||
const cellCurrentValue = editRow.cells[colId].peek();
|
||||
const isFormula = column.isFormula() || column.hasTriggerFormula();
|
||||
if (isFormula && isRaisedException(cellCurrentValue)) {
|
||||
const formulaError = Observable.create(null, cellCurrentValue);
|
||||
gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId())
|
||||
.then(value => {
|
||||
formulaError.set(value);
|
||||
})
|
||||
.catch(reportError);
|
||||
return formulaError;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,22 @@ import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorBu
|
||||
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
|
||||
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||
import {undef} from 'app/common/gutil';
|
||||
import {Computed, dom, Observable, styled} from 'grainjs';
|
||||
import {Computed, Disposable, dom, MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
||||
import {isRaisedException} from "app/common/gristTypes";
|
||||
import {decodeObject, RaisedException} from "app/plugin/objtypes";
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ColumnRec} from 'app/client/models/DocModel';
|
||||
import {asyncOnce} from 'app/common/AsyncCreate';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {CellValue} from 'app/common/DocActions';
|
||||
import debounce = require('lodash/debounce');
|
||||
|
||||
// How wide to expand the FormulaEditor when an error is shown in it.
|
||||
const minFormulaErrorWidth = 400;
|
||||
|
||||
export interface IFormulaEditorOptions extends Options {
|
||||
cssClass?: string;
|
||||
editingFormula?: ko.Computed<boolean>,
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +47,8 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
constructor(options: IFormulaEditorOptions) {
|
||||
super(options);
|
||||
|
||||
const editingFormula = options.editingFormula || options.field.editingFormula;
|
||||
|
||||
const initialValue = undef(options.state as string | undefined, options.editValue, String(options.cellValue));
|
||||
// create editor state observable (used by draft and latest position memory)
|
||||
this.editorState = Observable.create(this, initialValue);
|
||||
@@ -59,7 +68,7 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
? Object.assign({ setCursor: this._onSetCursor }, options.commands)
|
||||
// for readonly mode don't grab cursor when clicked away - just move the cursor
|
||||
: options.commands;
|
||||
this._commandGroup = this.autoDispose(createGroup(allCommands, this, options.field.editingFormula));
|
||||
this._commandGroup = this.autoDispose(createGroup(allCommands, this, editingFormula));
|
||||
|
||||
const hideErrDetails = Observable.create(this, true);
|
||||
const raisedException = Computed.create(this, use => {
|
||||
@@ -109,14 +118,14 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
|
||||
// enable formula editing if state was passed
|
||||
if (options.state || options.readonly) {
|
||||
options.field.editingFormula(true);
|
||||
editingFormula(true);
|
||||
}
|
||||
if (options.readonly) {
|
||||
this._formulaEditor.enable(false);
|
||||
aceObj.gotoLine(0, 0); // By moving, ace editor won't highlight anything
|
||||
}
|
||||
// This catches any change to the value including e.g. via backspace or paste.
|
||||
aceObj.once("change", () => options.field.editingFormula(true));
|
||||
aceObj.once("change", () => editingFormula(true));
|
||||
})
|
||||
),
|
||||
(options.formulaError ?
|
||||
@@ -234,7 +243,150 @@ function _isInIdentifier(line: string, column: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a formula editor. Returns a Disposable that owns the editor.
|
||||
*/
|
||||
export function openFormulaEditor(options: {
|
||||
gristDoc: GristDoc,
|
||||
field: ViewFieldRec,
|
||||
// Associated formula from a diffrent column (for example style rule).
|
||||
column?: ColumnRec,
|
||||
// Needed to get exception value, if any.
|
||||
editRow?: DataRowModel,
|
||||
// Element over which to position the editor.
|
||||
refElem: Element,
|
||||
editValue?: string,
|
||||
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
||||
onCancel?: () => void,
|
||||
// Called after editor is created to set up editor cleanup (e.g. saving on click-away).
|
||||
setupCleanup: (
|
||||
owner: MultiHolder,
|
||||
doc: GristDoc,
|
||||
field: ViewFieldRec,
|
||||
save: () => Promise<void>
|
||||
) => void,
|
||||
}): Disposable {
|
||||
const {gristDoc, field, editRow, refElem, setupCleanup} = options;
|
||||
const holder = MultiHolder.create(null);
|
||||
const column = options.column ? options.column : field.origCol();
|
||||
|
||||
// AsyncOnce ensures it's called once even if triggered multiple times.
|
||||
const saveEdit = asyncOnce(async () => {
|
||||
const formula = editor.getCellValue();
|
||||
if (options.onSave) {
|
||||
await options.onSave(column, formula as string);
|
||||
} else if (formula !== column.formula.peek()) {
|
||||
await column.updateColValues({formula});
|
||||
}
|
||||
holder.dispose();
|
||||
});
|
||||
|
||||
// These are the commands for while the editor is active.
|
||||
const editCommands = {
|
||||
fieldEditSave: () => { saveEdit().catch(reportError); },
|
||||
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
|
||||
fieldEditCancel: () => { holder.dispose(); options.onCancel?.(); },
|
||||
};
|
||||
|
||||
// Replace the item in the Holder with a new one, disposing the previous one.
|
||||
const editor = FormulaEditor.create(holder, {
|
||||
gristDoc,
|
||||
field,
|
||||
cellValue: column.formula(),
|
||||
formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
|
||||
editValue: options.editValue,
|
||||
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
|
||||
commands: editCommands,
|
||||
cssClass: 'formula_editor_sidepane',
|
||||
readonly : false
|
||||
});
|
||||
editor.attach(refElem);
|
||||
|
||||
// When formula is empty enter formula-editing mode (highlight formula icons; click on a column inserts its ID).
|
||||
// This function is used for primarily for switching between different column behaviors, so we want to enter full
|
||||
// edit mode right away.
|
||||
// TODO: consider converting it to parameter, when this will be used in different scenarios.
|
||||
if (!column.formula()) {
|
||||
field.editingFormula(true);
|
||||
}
|
||||
setupCleanup(holder, gristDoc, field, saveEdit);
|
||||
return holder;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the cell at the given row and column is a formula value containing an exception, return an
|
||||
* observable with this exception, and fetch more details to add to the observable.
|
||||
*/
|
||||
export function getFormulaError(
|
||||
gristDoc: GristDoc, editRow: DataRowModel, column: ColumnRec
|
||||
): Observable<CellValue>|undefined {
|
||||
const colId = column.colId.peek();
|
||||
const cellCurrentValue = editRow.cells[colId].peek();
|
||||
const isFormula = column.isFormula() || column.hasTriggerFormula();
|
||||
if (isFormula && isRaisedException(cellCurrentValue)) {
|
||||
const formulaError = Observable.create(null, cellCurrentValue);
|
||||
gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId())
|
||||
.then(value => {
|
||||
formulaError.set(value);
|
||||
})
|
||||
.catch(reportError);
|
||||
return formulaError;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and return an observable for the count of errors in a column, which gets updated in
|
||||
* response to changes in origColumn and in user data.
|
||||
*/
|
||||
export function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, origColumn: ColumnRec) {
|
||||
const errorMessage = Observable.create(owner, '');
|
||||
|
||||
// Count errors in origColumn when it's a formula column. Counts get cached by the
|
||||
// tableData.countErrors() method, and invalidated on relevant data changes.
|
||||
function countErrors() {
|
||||
if (owner.isDisposed()) { return; }
|
||||
const tableData = gristDoc.docData.getTable(origColumn.table.peek().tableId.peek());
|
||||
const isFormula = origColumn.isRealFormula.peek() || origColumn.hasTriggerFormula.peek();
|
||||
if (tableData && isFormula) {
|
||||
const colId = origColumn.colId.peek();
|
||||
const numCells = tableData.getColValues(colId)?.length || 0;
|
||||
const numErrors = tableData.countErrors(colId) || 0;
|
||||
errorMessage.set(
|
||||
(numErrors === 0) ? '' :
|
||||
(numCells === 1) ? `Error in the cell` :
|
||||
(numErrors === numCells) ? `Errors in all ${numErrors} cells` :
|
||||
`Errors in ${numErrors} of ${numCells} cells`
|
||||
);
|
||||
} else {
|
||||
errorMessage.set('');
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce the count calculation to defer it to the end of a bundle of actions.
|
||||
const debouncedCountErrors = debounce(countErrors, 0);
|
||||
|
||||
// If there is an update to the data in the table, count errors again. Since the same UI is
|
||||
// reused when different page widgets are selected, we need to re-create this subscription
|
||||
// whenever the selected table changes. We use a Computed to both react to changes and dispose
|
||||
// the previous subscription when it changes.
|
||||
Computed.create(owner, (use) => {
|
||||
const tableData = gristDoc.docData.getTable(use(use(origColumn.table).tableId));
|
||||
return tableData ? use.owner.autoDispose(tableData.tableActionEmitter.addListener(debouncedCountErrors)) : null;
|
||||
});
|
||||
|
||||
// The counts depend on the origColumn and its isRealFormula status, but with the debounced
|
||||
// callback and subscription to data, subscribe to relevant changes manually (rather than using
|
||||
// a Computed).
|
||||
owner.autoDispose(subscribe(use => { use(origColumn.id); use(origColumn.isRealFormula); debouncedCountErrors(); }));
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
const cssCollapseIcon = styled(icon, `
|
||||
margin: -3px 4px 0 4px;
|
||||
--icon-color: ${colors.slate};
|
||||
`);
|
||||
|
||||
export const cssError = styled('div', `
|
||||
color: ${colors.error};
|
||||
`);
|
||||
|
||||
@@ -3,14 +3,19 @@
|
||||
* so is friendlier and clearer to derive TypeScript classes from.
|
||||
*/
|
||||
import {DocComm} from 'app/client/components/DocComm';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {DocData} from 'app/client/models/DocData';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {SaveableObjObservable} from 'app/client/models/modelUtil';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {colorSelect} from 'app/client/ui2018/ColorSelect';
|
||||
import {CellStyle} from 'app/client/widgets/CellStyle';
|
||||
import {BaseFormatter} from 'app/common/ValueFormatter';
|
||||
import {Computed, Disposable, DomContents, fromKo, Observable} from 'grainjs';
|
||||
|
||||
import {
|
||||
Disposable,
|
||||
dom,
|
||||
DomContents,
|
||||
fromKo,
|
||||
Observable,
|
||||
} from 'grainjs';
|
||||
|
||||
export interface Options {
|
||||
// A hex value to set the default widget text color. Default to '#000000' if omitted.
|
||||
@@ -33,42 +38,33 @@ export abstract class NewAbstractWidget extends Disposable {
|
||||
protected valueFormatter: Observable<BaseFormatter>;
|
||||
protected textColor: Observable<string>;
|
||||
protected fillColor: Observable<string>;
|
||||
protected readonly defaultTextColor: string;
|
||||
|
||||
constructor(protected field: ViewFieldRec, opts: Options = {}) {
|
||||
super();
|
||||
const {defaultTextColor = '#000000'} = opts;
|
||||
this.defaultTextColor = defaultTextColor;
|
||||
this.options = field.widgetOptionsJson;
|
||||
this.textColor = Computed.create(this, (use) => (
|
||||
use(this.field.textColor) || defaultTextColor
|
||||
)).onWrite((val) => this.field.textColor(val === defaultTextColor ? undefined : val));
|
||||
this.fillColor = fromKo(this.field.fillColor);
|
||||
|
||||
this.valueFormatter = fromKo(field.formatter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the DOM showing configuration buttons and fields in the sidebar.
|
||||
*/
|
||||
public buildConfigDom(): DomContents { return null; }
|
||||
public buildConfigDom(): DomContents {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the transform prompt config DOM in the few cases where it is necessary.
|
||||
* Child classes need not override this function if they do not require transform config options.
|
||||
*/
|
||||
public buildTransformConfigDom(): DomContents { return null; }
|
||||
public buildTransformConfigDom(): DomContents {
|
||||
return null;
|
||||
}
|
||||
|
||||
public buildColorConfigDom(): Element[] {
|
||||
return [
|
||||
cssLabel('CELL COLOR'),
|
||||
cssRow(
|
||||
colorSelect(
|
||||
this.textColor,
|
||||
this.fillColor,
|
||||
// Calling `field.widgetOptionsJson.save()` saves both fill and text color settings.
|
||||
() => this.field.widgetOptionsJson.save()
|
||||
)
|
||||
)
|
||||
];
|
||||
public buildColorConfigDom(gristDoc: GristDoc): DomContents {
|
||||
return dom.create(CellStyle, this.field, gristDoc, this.defaultTextColor);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,5 +84,7 @@ export abstract class NewAbstractWidget extends Disposable {
|
||||
/**
|
||||
* Returns the docComm object for communicating with the server.
|
||||
*/
|
||||
protected _getDocComm(): DocComm { return this._getDocData().docComm; }
|
||||
protected _getDocComm(): DocComm {
|
||||
return this._getDocData().docComm;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user