(core) Implementing row conditional formatting

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

Test Plan: Existing and new tests

Reviewers: paulfitz, georgegevoian

Reviewed By: georgegevoian

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

View File

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

View File

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

View File

@@ -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 cssLine = styled('div', `
display: flex;
margin: 16px 16px 12px 16px;
justify-content: space-between;
align-items: baseline;
`);
const cssLabel = styled('div', `
text-transform: uppercase;
font-size: ${vars.xsmallFontSize};
`);
const cssButton = styled(textButton, `
font-size: ${vars.mediumFontSize};
`);
const cssRow = styled('div', `
display: flex;
margin: 8px 16px;
align-items: center;
&-top-space {
margin-top: 24px;
}
&-disabled {
color: ${colors.slate};
}
`);
const cssLineLabel = styled(cssLabel, `
margin-top: 0px;
margin-bottom: 0px;
`);
const cssRuleList = styled('div', `
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
margin-bottom: 12px;
`);
const cssErrorBorder = styled('div', `
border-color: ${colors.error};
`);
const cssRuleError = styled(cssError, `
margin: 2px 0px 10px 0px;
`);
const cssColumnsRow = styled(cssRow, `
align-items: flex-start;
margin-top: 0px;
margin-bottom: 0px;
`);
const cssLeftColumn = styled('div', `
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
`);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,266 @@
import {GristDoc} from 'app/client/components/GristDoc';
import {ColumnRec} from 'app/client/models/DocModel';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {RuleOwner} from 'app/client/models/RuleOwner';
import {Style} from 'app/client/models/Styles';
import {cssFieldFormula} from 'app/client/ui/FieldConfig';
import {textButton} from 'app/client/ui2018/buttons';
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {setupEditorCleanup} from 'app/client/widgets/FieldEditor';
import {cssError, openFormulaEditor} from 'app/client/widgets/FormulaEditor';
import {isRaisedException, isValidRuleValue} from 'app/common/gristTypes';
import {RowRecord} from 'app/plugin/GristData';
import {Computed, Disposable, dom, DomContents, makeTestId, Observable, styled} from 'grainjs';
import debounce = require('lodash/debounce');
const testId = makeTestId('test-widget-style-');
export class ConditionalStyle extends Disposable {
// Holds data from currently selected record (holds data only when this field has conditional styles).
private _currentRecord: Computed<RowRecord | undefined>;
// Helper field for refreshing current record data.
private _dataChangeTrigger = Observable.create(this, 0);
constructor(
private _label: string,
private _ruleOwner: RuleOwner,
private _gristDoc: GristDoc,
) {
super();
this._currentRecord = Computed.create(this, use => {
if (!use(this._ruleOwner.hasRules)) {
return;
}
// As we are not subscribing to data change, we will monitor actions
// that are sent from the server to refresh this computed observable.
void use(this._dataChangeTrigger);
const tableId = use(_ruleOwner.tableId);
const tableData = _gristDoc.docData.getTable(tableId)!;
const cursor = use(_gristDoc.cursorPosition);
// Make sure we are not on the new row.
if (!cursor || typeof cursor.rowId !== 'number') {
return undefined;
}
return tableData.getRecord(cursor.rowId);
});
// Here we will subscribe to tableActionEmitter, and update currentRecord observable.
// We have 'dataChangeTrigger' that is just a number that will be updated every time
// we received some table actions.
const debouncedUpdate = debounce(() => {
if (this._dataChangeTrigger.isDisposed()) {
return;
}
this._dataChangeTrigger.set(this._dataChangeTrigger.get() + 1);
}, 0);
Computed.create(this, (use) => {
const tableId = use(_ruleOwner.tableId);
const tableData = _gristDoc.docData.getTable(tableId);
return tableData ? use.owner.autoDispose(tableData.tableActionEmitter.addListener(debouncedUpdate)) : null;
});
}
public buildDom(): DomContents {
return [
cssRow(
{ style: 'margin-top: 16px' },
textButton(
'Add conditional style',
testId('add-conditional-style'),
dom.on('click', () => this._ruleOwner.addEmptyRule())
),
dom.hide(use => use(this._ruleOwner.hasRules))
),
dom.domComputedOwned(
use => use(this._ruleOwner.rulesCols),
(owner, rules) =>
cssRuleList(
dom.show(rules.length > 0),
...rules.map((column, ruleIndex) => {
const textColor = this._buildStyleOption(owner, ruleIndex, 'textColor');
const fillColor = this._buildStyleOption(owner, ruleIndex, 'fillColor');
const fontBold = this._buildStyleOption(owner, ruleIndex, 'fontBold');
const fontItalic = this._buildStyleOption(owner, ruleIndex, 'fontItalic');
const fontUnderline = this._buildStyleOption(owner, ruleIndex, 'fontUnderline');
const fontStrikethrough = this._buildStyleOption(owner, ruleIndex, 'fontStrikethrough');
const save = async () => {
// This will save both options.
await this._ruleOwner.rulesStyles.save();
};
const currentValue = Computed.create(owner, use => {
const record = use(this._currentRecord);
if (!record) {
return null;
}
const value = record[use(column.colId)];
return value ?? null;
});
const hasError = Computed.create(owner, use => {
return !isValidRuleValue(use(currentValue));
});
const errorMessage = Computed.create(owner, use => {
const value = use(currentValue);
return (!use(hasError) ? '' :
isRaisedException(value) ? 'Error in style rule' :
'Rule must return True or False');
});
return dom('div',
testId(`conditional-rule-${ruleIndex}`),
testId(`conditional-rule`), // for testing
cssLineLabel('IF...'),
cssColumnsRow(
cssLeftColumn(
this._buildRuleFormula(column.formula, column, hasError),
cssRuleError(
dom.text(errorMessage),
dom.show(hasError),
testId(`rule-error-${ruleIndex}`),
),
colorSelect(
{
textColor: new ColorOption({color:textColor, allowsNone: true, noneText: 'default'}),
fillColor: new ColorOption({color:fillColor, allowsNone: true, noneText: 'none'}),
fontBold,
fontItalic,
fontUnderline,
fontStrikethrough
},
save,
this._label || 'Conditional Style'
)
),
cssRemoveButton(
'Remove',
testId(`remove-rule-${ruleIndex}`),
dom.on('click', () => this._ruleOwner.removeRule(ruleIndex))
)
)
);
})
)
),
cssRow(
textButton('Add another rule',
dom.on('click', () => this._ruleOwner.addEmptyRule()),
testId('add-another-rule'),
),
dom.show(use => use(this._ruleOwner.hasRules))
),
];
}
private _buildStyleOption<T extends keyof Style>(owner: Disposable, index: number, option: T) {
const obs = Computed.create(owner, use => {
const styles = use(this._ruleOwner.rulesStyles);
return styles?.[index]?.[option];
});
obs.onWrite(value => {
const list = Array.from(this._ruleOwner.rulesStyles.peek() ?? []);
list[index] = list[index] ?? {};
list[index][option] = value;
this._ruleOwner.rulesStyles(list);
});
return obs;
}
private _buildRuleFormula(
formula: KoSaveableObservable<string>,
column: ColumnRec,
hasError: Observable<boolean>
) {
return cssFieldFormula(
formula,
{ maxLines: 1 },
dom.cls('formula_field_sidepane'),
dom.cls(cssErrorBorder.className, hasError),
{ tabIndex: '-1' },
dom.on('focus', (_, refElem) => {
const section = this._gristDoc.viewModel.activeSection();
const vsi = section.viewInstance();
const editorHolder = openFormulaEditor({
gristDoc: this._gristDoc,
editingFormula: section.editingFormula,
column,
editRow: vsi?.moveEditRowToCursor(),
refElem,
setupCleanup: setupEditorCleanup,
});
// Add editor to document holder - this will prevent multiple formula editor instances.
this._gristDoc.fieldEditorHolder.autoDispose(editorHolder);
})
);
}
}
const cssIcon = styled(icon, `
flex: 0 0 auto;
--icon-color: ${colors.slate};
`);
const cssLabel = styled('div', `
text-transform: uppercase;
margin: 16px 16px 12px 16px;
font-size: ${vars.xsmallFontSize};
`);
const cssRow = styled('div', `
display: flex;
margin: 8px 16px;
align-items: center;
&-top-space {
margin-top: 24px;
}
&-disabled {
color: ${colors.slate};
}
`);
const cssRemoveButton = styled(cssIcon, `
flex: none;
margin: 6px;
margin-right: 0px;
transform: translateY(4px);
cursor: pointer;
--icon-color: ${colors.slate};
&:hover {
--icon-color: ${colors.lightGreen};
}
`);
const cssLineLabel = styled(cssLabel, `
margin-top: 0px;
margin-bottom: 0px;
`);
const cssRuleList = styled('div', `
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
margin-bottom: 12px;
`);
const cssErrorBorder = styled('div', `
border-color: ${colors.error};
`);
const cssRuleError = styled(cssError, `
margin: 2px 0px 10px 0px;
`);
const cssColumnsRow = styled(cssRow, `
align-items: flex-start;
margin-top: 0px;
margin-bottom: 0px;
`);
const cssLeftColumn = styled('div', `
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
`);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import {Options} from 'app/client/widgets/NewBaseEditor';
import {FieldOptions} from 'app/client/widgets/NewBaseEditor';
import {NTextEditor} from 'app/client/widgets/NTextEditor';
/**
@@ -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');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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