mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
ec157dc469
Summary: Adds initial implementation of dark mode. Preferences for dark mode are available on the account settings page. Dark mode is currently a beta feature as there are still some small bugs to squash and a few remaining UI elements to style. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Subscribers: paulfitz, jarek Differential Revision: https://phab.getgrist.com/D3587
267 lines
9.0 KiB
TypeScript
267 lines
9.0 KiB
TypeScript
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 {theme, 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;
|
|
`);
|
|
|
|
const cssLabel = styled('div', `
|
|
text-transform: uppercase;
|
|
margin: 16px 16px 12px 16px;
|
|
color: ${theme.text};
|
|
font-size: ${vars.xsmallFontSize};
|
|
`);
|
|
|
|
const cssRow = styled('div', `
|
|
display: flex;
|
|
margin: 8px 16px;
|
|
align-items: center;
|
|
&-top-space {
|
|
margin-top: 24px;
|
|
}
|
|
&-disabled {
|
|
color: ${theme.disabledText};
|
|
}
|
|
`);
|
|
|
|
const cssRemoveButton = styled(cssIcon, `
|
|
flex: none;
|
|
margin: 6px;
|
|
margin-right: 0px;
|
|
transform: translateY(4px);
|
|
cursor: pointer;
|
|
--icon-color: ${theme.controlSecondaryFg};
|
|
&:hover {
|
|
--icon-color: ${theme.controlPrimaryFg};
|
|
}
|
|
`);
|
|
|
|
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: ${theme.inputInvalid};
|
|
`);
|
|
|
|
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;
|
|
`);
|