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/D3547pull/13/head
parent
fbba6b8f52
commit
9e4d802405
@ -0,0 +1,41 @@
|
||||
import {ColumnRec, DocModel} from 'app/client/models/DocModel';
|
||||
import {Style} from 'app/client/models/Styles';
|
||||
import * as modelUtil from 'app/client/models/modelUtil';
|
||||
|
||||
export interface RuleOwner {
|
||||
// Field or Section can have a list of conditional styling rules. Each style is a combination of a formula and options
|
||||
// that must by applied. Style is persisted as a new hidden formula column and the list of such
|
||||
// columns is stored as Reference List property ('rules') in a field or column.
|
||||
tableId: ko.Computed<string>;
|
||||
// If this field (or column) has a list of conditional styling rules.
|
||||
hasRules: ko.Computed<boolean>;
|
||||
// List of columns that are used as rules for conditional styles.
|
||||
rulesCols: ko.Computed<ColumnRec[]>;
|
||||
// List of columns ids that are used as rules for conditional styles.
|
||||
rulesColsIds: ko.Computed<string[]>;
|
||||
// List of styles used by conditional rules.
|
||||
rulesStyles: modelUtil.KoSaveableObservable<Style[]>;
|
||||
// Adds empty conditional style rule. Sets before sending to the server.
|
||||
addEmptyRule(): Promise<void>;
|
||||
// Removes one rule from the collection. Removes before sending update to the server.
|
||||
removeRule(index: number): Promise<void>;
|
||||
}
|
||||
|
||||
export async function removeRule(docModel: DocModel, owner: RuleOwner, index: number) {
|
||||
const col = owner.rulesCols.peek()[index];
|
||||
if (!col) {
|
||||
throw new Error(`There is no rule at index ${index}`);
|
||||
}
|
||||
const newStyles = owner.rulesStyles.peek()?.slice() ?? [];
|
||||
if (newStyles.length >= index) {
|
||||
newStyles.splice(index, 1);
|
||||
} else {
|
||||
console.debug(`There are not style options at index ${index}`);
|
||||
}
|
||||
await docModel.docData.bundleActions("Remove conditional rule", () =>
|
||||
Promise.all([
|
||||
owner.rulesStyles.setAndSave(newStyles),
|
||||
docModel.docData.sendAction(['RemoveColumn', owner.tableId.peek(), col.colId.peek()])
|
||||
])
|
||||
);
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {styled} from 'grainjs';
|
||||
|
||||
export const cssIcon = styled(icon, `
|
||||
flex: 0 0 auto;
|
||||
--icon-color: ${colors.slate};
|
||||
`);
|
||||
|
||||
export const cssLabel = styled('div', `
|
||||
text-transform: uppercase;
|
||||
margin: 16px 16px 12px 16px;
|
||||
font-size: ${vars.xsmallFontSize};
|
||||
`);
|
||||
|
||||
export const cssRow = styled('div', `
|
||||
display: flex;
|
||||
margin: 8px 16px;
|
||||
align-items: center;
|
||||
&-top-space {
|
||||
margin-top: 24px;
|
||||
}
|
||||
&-disabled {
|
||||
color: ${colors.slate};
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssBlockedCursor = styled('span', `
|
||||
&, & * {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssButtonRow = styled(cssRow, `
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
& > button {
|
||||
margin-left: 16px;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssSeparator = styled('div', `
|
||||
border-bottom: 1px solid ${colors.mediumGrey};
|
||||
margin-top: 16px;
|
||||
`);
|
@ -1,265 +1,88 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ColumnRec} from 'app/client/models/DocModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
||||
import {Style} from 'app/client/models/Styles';
|
||||
import {cssFieldFormula} from 'app/client/ui/FieldConfig';
|
||||
import {cssIcon, cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {textButton} from 'app/client/ui2018/buttons';
|
||||
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {setupEditorCleanup} from 'app/client/widgets/FieldEditor';
|
||||
import {cssError, openFormulaEditor} from 'app/client/widgets/FormulaEditor';
|
||||
import {isRaisedException, isValidRuleValue} from 'app/common/gristTypes';
|
||||
import {RowRecord} from 'app/plugin/GristData';
|
||||
import {Computed, Disposable, dom, DomContents, fromKo, makeTestId, MultiHolder, Observable, styled} from 'grainjs';
|
||||
import debounce = require('lodash/debounce');
|
||||
|
||||
const testId = makeTestId('test-widget-style-');
|
||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||
import {ConditionalStyle} from 'app/client/widgets/ConditionalStyle';
|
||||
import {Disposable, dom, DomContents, fromKo, MultiHolder, Observable, styled} from 'grainjs';
|
||||
|
||||
export class CellStyle extends Disposable {
|
||||
protected textColor: Observable<string|undefined>;
|
||||
protected fillColor: Observable<string|undefined>;
|
||||
protected fontBold: Observable<boolean|undefined>;
|
||||
protected fontUnderline: Observable<boolean|undefined>;
|
||||
protected fontItalic: Observable<boolean|undefined>;
|
||||
protected fontStrikethrough: Observable<boolean|undefined>;
|
||||
// Holds data from currently selected record (holds data only when this field has conditional styles).
|
||||
protected currentRecord: Computed<RowRecord | undefined>;
|
||||
// Helper field for refreshing current record data.
|
||||
protected dataChangeTrigger = Observable.create(this, 0);
|
||||
private _textColor: Observable<string|undefined>;
|
||||
private _fillColor: Observable<string|undefined>;
|
||||
private _fontBold: Observable<boolean|undefined>;
|
||||
private _fontUnderline: Observable<boolean|undefined>;
|
||||
private _fontItalic: Observable<boolean|undefined>;
|
||||
private _fontStrikethrough: Observable<boolean|undefined>;
|
||||
|
||||
constructor(
|
||||
protected field: ViewFieldRec,
|
||||
protected gristDoc: GristDoc,
|
||||
protected defaultTextColor: string
|
||||
private _field: ViewFieldRec,
|
||||
private _gristDoc: GristDoc,
|
||||
private _defaultTextColor: string
|
||||
) {
|
||||
super();
|
||||
this.textColor = fromKo(this.field.textColor);
|
||||
this.fillColor = fromKo(this.field.fillColor);
|
||||
this.fontBold = fromKo(this.field.fontBold);
|
||||
this.fontUnderline = fromKo(this.field.fontUnderline);
|
||||
this.fontItalic = fromKo(this.field.fontItalic);
|
||||
this.fontStrikethrough = fromKo(this.field.fontStrikethrough);
|
||||
this.currentRecord = Computed.create(this, use => {
|
||||
if (!use(this.field.hasRules)) {
|
||||
return;
|
||||
}
|
||||
// As we are not subscribing to data change, we will monitor actions
|
||||
// that are sent from the server to refresh this computed observable.
|
||||
void use(this.dataChangeTrigger);
|
||||
const tableId = use(use(use(field.column).table).tableId);
|
||||
const tableData = gristDoc.docData.getTable(tableId)!;
|
||||
const cursor = use(gristDoc.cursorPosition);
|
||||
// Make sure we are not on the new row.
|
||||
if (!cursor || typeof cursor.rowId !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
return tableData.getRecord(cursor.rowId);
|
||||
});
|
||||
|
||||
// Here we will subscribe to tableActionEmitter, and update currentRecord observable.
|
||||
// We have 'dataChangeTrigger' that is just a number that will be updated every time
|
||||
// we received some table actions.
|
||||
const debouncedUpdate = debounce(() => {
|
||||
if (this.dataChangeTrigger.isDisposed()) {
|
||||
return;
|
||||
}
|
||||
this.dataChangeTrigger.set(this.dataChangeTrigger.get() + 1);
|
||||
}, 0);
|
||||
Computed.create(this, (use) => {
|
||||
const tableId = use(use(use(field.column).table).tableId);
|
||||
const tableData = gristDoc.docData.getTable(tableId);
|
||||
return tableData ? use.owner.autoDispose(tableData.tableActionEmitter.addListener(debouncedUpdate)) : null;
|
||||
});
|
||||
this._textColor = fromKo(this._field.textColor);
|
||||
this._fillColor = fromKo(this._field.fillColor);
|
||||
this._fontBold = fromKo(this._field.fontBold);
|
||||
this._fontUnderline = fromKo(this._field.fontUnderline);
|
||||
this._fontItalic = fromKo(this._field.fontItalic);
|
||||
this._fontStrikethrough = fromKo(this._field.fontStrikethrough);
|
||||
}
|
||||
|
||||
public buildDom(): DomContents {
|
||||
const holder = new MultiHolder();
|
||||
return [
|
||||
cssLabel('CELL STYLE', dom.autoDispose(holder)),
|
||||
cssLine(
|
||||
cssLabel('CELL STYLE', dom.autoDispose(holder)),
|
||||
cssButton('Open row styles', dom.on('click', allCommands.viewTabOpen.run)),
|
||||
),
|
||||
cssRow(
|
||||
colorSelect(
|
||||
{
|
||||
textColor: new ColorOption(this.textColor, false, this.defaultTextColor),
|
||||
fillColor: new ColorOption(this.fillColor, true, '', 'none', '#FFFFFF'),
|
||||
fontBold: this.fontBold,
|
||||
fontItalic: this.fontItalic,
|
||||
fontUnderline: this.fontUnderline,
|
||||
fontStrikethrough: this.fontStrikethrough
|
||||
textColor: new ColorOption(
|
||||
{ color: this._textColor, defaultColor: this._defaultTextColor, noneText: 'default'}
|
||||
),
|
||||
fillColor: new ColorOption(
|
||||
{ color: this._fillColor, allowsNone: true, noneText: 'none'}
|
||||
),
|
||||
fontBold: this._fontBold,
|
||||
fontItalic: this._fontItalic,
|
||||
fontUnderline: this._fontUnderline,
|
||||
fontStrikethrough: this._fontStrikethrough
|
||||
},
|
||||
// Calling `field.widgetOptionsJson.save()` saves both fill and text color settings.
|
||||
() => this.field.widgetOptionsJson.save()
|
||||
() => this._field.widgetOptionsJson.save()
|
||||
)
|
||||
),
|
||||
cssRow(
|
||||
{style: 'margin-top: 16px'},
|
||||
textButton(
|
||||
'Add conditional style',
|
||||
testId('add-conditional-style'),
|
||||
dom.on('click', () => this.field.addEmptyRule())
|
||||
),
|
||||
dom.hide(this.field.hasRules)
|
||||
),
|
||||
dom.domComputedOwned(
|
||||
use => use(this.field.rulesCols),
|
||||
(owner, rules) =>
|
||||
cssRuleList(
|
||||
dom.show(rules.length > 0),
|
||||
...rules.map((column, ruleIndex) => {
|
||||
const textColor = this._buildStyleOption(owner, ruleIndex, 'textColor');
|
||||
const fillColor = this._buildStyleOption(owner, ruleIndex, 'fillColor');
|
||||
const fontBold = this._buildStyleOption(owner, ruleIndex, 'fontBold');
|
||||
const fontItalic = this._buildStyleOption(owner, ruleIndex, 'fontItalic');
|
||||
const fontUnderline = this._buildStyleOption(owner, ruleIndex, 'fontUnderline');
|
||||
const fontStrikethrough = this._buildStyleOption(owner, ruleIndex, 'fontStrikethrough');
|
||||
const save = async () => {
|
||||
// This will save both options.
|
||||
await this.field.rulesStyles.save();
|
||||
};
|
||||
const currentValue = Computed.create(owner, use => {
|
||||
const record = use(this.currentRecord);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const value = record[use(column.colId)];
|
||||
return value ?? null;
|
||||
});
|
||||
const hasError = Computed.create(owner, use => {
|
||||
return !isValidRuleValue(use(currentValue));
|
||||
});
|
||||
const errorMessage = Computed.create(owner, use => {
|
||||
const value = use(currentValue);
|
||||
return (!use(hasError) ? '' :
|
||||
isRaisedException(value) ? 'Error in style rule' :
|
||||
'Rule must return True or False');
|
||||
});
|
||||
return dom('div',
|
||||
testId(`conditional-rule-${ruleIndex}`),
|
||||
testId(`conditional-rule`), // for testing
|
||||
cssLineLabel('IF...'),
|
||||
cssColumnsRow(
|
||||
cssLeftColumn(
|
||||
this._buildRuleFormula(column.formula, column, hasError),
|
||||
cssRuleError(
|
||||
dom.text(errorMessage),
|
||||
dom.show(hasError),
|
||||
testId(`rule-error-${ruleIndex}`),
|
||||
),
|
||||
colorSelect(
|
||||
{
|
||||
textColor: new ColorOption(textColor, true, '', 'default'),
|
||||
fillColor: new ColorOption(fillColor, true, '', 'none'),
|
||||
fontBold,
|
||||
fontItalic,
|
||||
fontUnderline,
|
||||
fontStrikethrough
|
||||
},
|
||||
save,
|
||||
'Cell style'
|
||||
)
|
||||
),
|
||||
cssRemoveButton(
|
||||
'Remove',
|
||||
testId(`remove-rule-${ruleIndex}`),
|
||||
dom.on('click', () => this.field.removeRule(ruleIndex))
|
||||
)
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
),
|
||||
cssRow(
|
||||
textButton('Add another rule'),
|
||||
testId('add-another-rule'),
|
||||
dom.on('click', () => this.field.addEmptyRule()),
|
||||
dom.show(this.field.hasRules)
|
||||
),
|
||||
dom.create(ConditionalStyle, "Cell Style", this._field, this._gristDoc)
|
||||
];
|
||||
}
|
||||
|
||||
private _buildStyleOption<T extends keyof Style>(owner: Disposable, index: number, option: T) {
|
||||
const obs = Computed.create(owner, use => use(this.field.rulesStyles)[index]?.[option]);
|
||||
obs.onWrite(value => {
|
||||
const list = Array.from(this.field.rulesStyles.peek() ?? []);
|
||||
list[index] = list[index] ?? {};
|
||||
list[index][option] = value as any;
|
||||
this.field.rulesStyles(list);
|
||||
});
|
||||
return obs;
|
||||
}
|
||||
|
||||
private _buildRuleFormula(
|
||||
formula: KoSaveableObservable<string>,
|
||||
column: ColumnRec,
|
||||
hasError: Observable<boolean>
|
||||
) {
|
||||
return cssFieldFormula(
|
||||
formula,
|
||||
{maxLines: 1},
|
||||
dom.cls('formula_field_sidepane'),
|
||||
dom.cls(cssErrorBorder.className, hasError),
|
||||
{tabIndex: '-1'},
|
||||
dom.on('focus', (_, refElem) => {
|
||||
const vsi = this.gristDoc.viewModel.activeSection().viewInstance();
|
||||
const editorHolder = openFormulaEditor({
|
||||
gristDoc: this.gristDoc,
|
||||
field: this.field,
|
||||
column,
|
||||
editRow: vsi?.moveEditRowToCursor(),
|
||||
refElem,
|
||||
setupCleanup: setupEditorCleanup,
|
||||
});
|
||||
// Add editor to document holder - this will prevent multiple formula editor instances.
|
||||
this.gristDoc.fieldEditorHolder.autoDispose(editorHolder);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cssRemoveButton = styled(cssIcon, `
|
||||
flex: none;
|
||||
margin: 6px;
|
||||
margin-right: 0px;
|
||||
transform: translateY(4px);
|
||||
cursor: pointer;
|
||||
--icon-color: ${colors.slate};
|
||||
&:hover {
|
||||
--icon-color: ${colors.lightGreen};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssLineLabel = styled(cssLabel, `
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
`);
|
||||
|
||||
const cssRuleList = styled('div', `
|
||||
const cssLine = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 12px;
|
||||
`);
|
||||
|
||||
const cssErrorBorder = styled('div', `
|
||||
border-color: ${colors.error};
|
||||
margin: 16px 16px 12px 16px;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
`);
|
||||
|
||||
const cssRuleError = styled(cssError, `
|
||||
margin: 2px 0px 10px 0px;
|
||||
const cssLabel = styled('div', `
|
||||
text-transform: uppercase;
|
||||
font-size: ${vars.xsmallFontSize};
|
||||
`);
|
||||
|
||||
const cssColumnsRow = styled(cssRow, `
|
||||
align-items: flex-start;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
const cssButton = styled(textButton, `
|
||||
font-size: ${vars.mediumFontSize};
|
||||
`);
|
||||
|
||||
const cssLeftColumn = styled('div', `
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
const cssRow = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 8px 16px;
|
||||
align-items: center;
|
||||
&-top-space {
|
||||
margin-top: 24px;
|
||||
}
|
||||
&-disabled {
|
||||
color: ${colors.slate};
|
||||
}
|
||||
`);
|
||||
|
@ -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;
|
||||
`);
|
@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import test_engine
|
||||
|
||||
|
||||
class TestGridRules(test_engine.EngineTestCase):
|
||||
# Helper for rules action
|
||||
def add_empty(self):
|
||||
return self.apply_user_action(['AddEmptyRule', "Table1", 0, 0])
|
||||
|
||||
|
||||
def set_rule(self, rule_index, formula):
|
||||
rules = self.engine.docmodel.tables.lookupOne(tableId='Table1').rawViewSectionRef.rules
|
||||
rule = list(rules)[rule_index]
|
||||
return self.apply_user_action(['UpdateRecord', '_grist_Tables_column',
|
||||
rule.id, {"formula": formula}])
|
||||
|
||||
|
||||
def remove_rule(self, rule_index):
|
||||
rules = self.engine.docmodel.tables.lookupOne(tableId='Table1').rawViewSectionRef.rules
|
||||
rule = list(rules)[rule_index]
|
||||
return self.apply_user_action(['RemoveColumn', 'Table1', rule.colId])
|
||||
|
||||
|
||||
def test_simple_rules(self):
|
||||
self.apply_user_action(['AddEmptyTable', None])
|
||||
self.apply_user_action(['AddRecord', "Table1", None, {"A": 1}])
|
||||
self.apply_user_action(['AddRecord', "Table1", None, {"A": 2}])
|
||||
self.apply_user_action(['AddRecord', "Table1", None, {"A": 3}])
|
||||
out_actions = self.add_empty()
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["AddColumn", "Table1", "gristHelper_RowConditionalRule",
|
||||
{"formula": "", "isFormula": True, "type": "Any"}],
|
||||
["AddRecord", "_grist_Tables_column", 5,
|
||||
{"colId": "gristHelper_RowConditionalRule", "formula": "", "isFormula": True,
|
||||
"label": "gristHelper_RowConditionalRule", "parentId": 1, "parentPos": 5.0,
|
||||
"type": "Any",
|
||||
"widgetOptions": ""}],
|
||||
["UpdateRecord", "_grist_Views_section", 2, {"rules": ["L", 5]}],
|
||||
]})
|
||||
out_actions = self.set_rule(0, "$A == 1")
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["ModifyColumn", "Table1", "gristHelper_RowConditionalRule",
|
||||
{"formula": "$A == 1"}],
|
||||
["UpdateRecord", "_grist_Tables_column", 5, {"formula": "$A == 1"}],
|
||||
["BulkUpdateRecord", "Table1", [1, 2, 3],
|
||||
{"gristHelper_RowConditionalRule": [True, False, False]}],
|
||||
]})
|
||||
|
||||
# Replace this rule with another rule to mark A = 2
|
||||
out_actions = self.set_rule(0, "$A == 2")
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["ModifyColumn", "Table1", "gristHelper_RowConditionalRule",
|
||||
{"formula": "$A == 2"}],
|
||||
["UpdateRecord", "_grist_Tables_column", 5, {"formula": "$A == 2"}],
|
||||
["BulkUpdateRecord", "Table1", [1, 2],
|
||||
{"gristHelper_RowConditionalRule": [False, True]}],
|
||||
]})
|
||||
|
||||
# Add another rule A = 3
|
||||
self.add_empty()
|
||||
out_actions = self.set_rule(1, "$A == 3")
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["ModifyColumn", "Table1", "gristHelper_RowConditionalRule2",
|
||||
{"formula": "$A == 3"}],
|
||||
["UpdateRecord", "_grist_Tables_column", 6, {"formula": "$A == 3"}],
|
||||
["BulkUpdateRecord", "Table1", [1, 2, 3],
|
||||
{"gristHelper_RowConditionalRule2": [False, False, True]}],
|
||||
]})
|
||||
|
||||
# Remove the last rule
|
||||
out_actions = self.remove_rule(1)
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["RemoveRecord", "_grist_Tables_column", 6],
|
||||
["UpdateRecord", "_grist_Views_section", 2, {"rules": ["L", 5]}],
|
||||
["RemoveColumn", "Table1", "gristHelper_RowConditionalRule2"]
|
||||
]})
|
||||
|
||||
# Remove last rule
|
||||
out_actions = self.remove_rule(0)
|
||||
self.assertPartialOutActions(out_actions, {"stored": [
|
||||
["RemoveRecord", "_grist_Tables_column", 5],
|
||||
["UpdateRecord", "_grist_Views_section", 2, {"rules": None}],
|
||||
["RemoveColumn", "Table1", "gristHelper_RowConditionalRule"]
|
||||
]})
|
Binary file not shown.
Loading…
Reference in new issue