mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -26,7 +26,7 @@ function AceEditor(options) {
|
||||
this.saveValueOnBlurEvent = !(options.saveValueOnBlurEvent === false);
|
||||
this.calcSize = options.calcSize || ((_elem, size) => size);
|
||||
this.gristDoc = options.gristDoc || null;
|
||||
this.field = options.field || null;
|
||||
this.column = options.column || null;
|
||||
this.editorState = options.editorState || null;
|
||||
this._readonly = options.readonly || false;
|
||||
|
||||
@@ -185,10 +185,10 @@ AceEditor.prototype.setFontSize = function(pxVal) {
|
||||
AceEditor.prototype._setup = function() {
|
||||
// Standard editor setup
|
||||
this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom));
|
||||
if (this.gristDoc && this.field) {
|
||||
if (this.gristDoc && this.column) {
|
||||
const getSuggestions = (prefix) => {
|
||||
const tableId = this.gristDoc.viewModel.activeSection().table().tableId();
|
||||
const columnId = this.field.column().colId();
|
||||
const columnId = this.column.colId();
|
||||
return this.gristDoc.docComm.autocomplete(prefix, tableId, columnId);
|
||||
};
|
||||
setupAceEditorCompletions(this.editor, {getSuggestions});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {reportError} from 'app/client/models/errors';
|
||||
import {KoSaveableObservable, ObjObservable, setSaveValue} from 'app/client/models/modelUtil';
|
||||
import {SortedRowSet} from 'app/client/models/rowset';
|
||||
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
||||
import {cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanel';
|
||||
import {cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
|
||||
import {cssFieldEntry, cssFieldLabel, IField, VisibleFieldsConfig } from 'app/client/ui/VisibleFieldsConfig';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {squareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
min-height: 16px;
|
||||
white-space: pre;
|
||||
word-wrap: break-word;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.g_record_detail_value.record-add {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import * as AceEditor from 'app/client/components/AceEditor';
|
||||
import {ColumnTransform} from 'app/client/components/ColumnTransform';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {cssButtonRow} from 'app/client/ui/RightPanel';
|
||||
import {cssButtonRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {testId} from 'app/client/ui2018/cssVars';
|
||||
import {FieldBuilder} from 'app/client/widgets/FieldBuilder';
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
/* globals alert, document, $ */
|
||||
|
||||
var _ = require('underscore');
|
||||
var ko = require('knockout');
|
||||
const _ = require('underscore');
|
||||
const ko = require('knockout');
|
||||
const debounce = require('lodash/debounce');
|
||||
|
||||
var gutil = require('app/common/gutil');
|
||||
var BinaryIndexedTree = require('app/common/BinaryIndexedTree');
|
||||
const gutil = require('app/common/gutil');
|
||||
const BinaryIndexedTree = require('app/common/BinaryIndexedTree');
|
||||
const {Sort} = require('app/common/SortSpec');
|
||||
|
||||
var dom = require('../lib/dom');
|
||||
var kd = require('../lib/koDom');
|
||||
var kf = require('../lib/koForm');
|
||||
var koDomScrolly = require('../lib/koDomScrolly');
|
||||
var tableUtil = require('../lib/tableUtil');
|
||||
var {addToSort, sortBy} = require('../lib/sortUtil');
|
||||
const dom = require('../lib/dom');
|
||||
const kd = require('../lib/koDom');
|
||||
const kf = require('../lib/koForm');
|
||||
const koDomScrolly = require('../lib/koDomScrolly');
|
||||
const tableUtil = require('../lib/tableUtil');
|
||||
const {addToSort, sortBy} = require('../lib/sortUtil');
|
||||
|
||||
var commands = require('./commands');
|
||||
var viewCommon = require('./viewCommon');
|
||||
var Base = require('./Base');
|
||||
var BaseView = require('./BaseView');
|
||||
var selector = require('./Selector');
|
||||
var {CopySelection} = require('./CopySelection');
|
||||
const commands = require('./commands');
|
||||
const viewCommon = require('./viewCommon');
|
||||
const Base = require('./Base');
|
||||
const BaseView = require('./BaseView');
|
||||
const selector = require('./Selector');
|
||||
const {CopySelection} = require('./CopySelection');
|
||||
const koUtil = require('app/client/lib/koUtil');
|
||||
const convert = require('color-convert');
|
||||
|
||||
const {renderAllRows} = require('app/client/components/Printing');
|
||||
const {reportError} = require('app/client/models/AppModel');
|
||||
@@ -40,6 +42,7 @@ const {contextMenu} = require('app/client/ui/contextMenu');
|
||||
const {menuToggle} = require('app/client/ui/MenuToggle');
|
||||
const {showTooltip} = require('app/client/ui/tooltips');
|
||||
const {parsePasteForView} = require("./BaseView2");
|
||||
const {CombinedStyle} = require("app/client/models/Styles");
|
||||
|
||||
|
||||
// A threshold for interpreting a motionless click as a click rather than a drag.
|
||||
@@ -1111,8 +1114,41 @@ GridView.prototype.buildDom = function() {
|
||||
// rows. IsCellActive is only subscribed to columns for the active row. This way, when
|
||||
// the cursor moves, there are (rows+2*columns) calls rather than rows*columns.
|
||||
var isRowActive = ko.computed(() => row._index() === self.cursor.rowIndex());
|
||||
|
||||
const computedFlags = ko.pureComputed(() => {
|
||||
return self.viewSection.rulesColsIds().map(colRef => {
|
||||
if (row.cells[colRef]) { return row.cells[colRef]() || false; }
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
const computedRule = koUtil.withKoUtils(ko.pureComputed(() => {
|
||||
if (row._isAddRow() || !row.id()) { return null; }
|
||||
const flags = computedFlags();
|
||||
if (flags.length === 0) { return null; }
|
||||
const styles = self.viewSection.rulesStyles() || [];
|
||||
return { style : new CombinedStyle(styles, flags) };
|
||||
}, this).extend({deferred: true}));
|
||||
|
||||
const fillColor = buildStyleOption(self, computedRule, 'fillColor');
|
||||
const zebraColor = ko.pureComputed(() => calcZebra(fillColor()));
|
||||
const textColor = buildStyleOption(self, computedRule, 'textColor');
|
||||
const fontBold = buildStyleOption(self, computedRule, 'fontBold');
|
||||
const fontItalic = buildStyleOption(self, computedRule, 'fontItalic');
|
||||
const fontUnderline = buildStyleOption(self, computedRule, 'fontUnderline');
|
||||
const fontStrikethrough = buildStyleOption(self, computedRule, 'fontStrikethrough');
|
||||
|
||||
return dom('div.gridview_row',
|
||||
dom.autoDispose(isRowActive),
|
||||
dom.autoDispose(computedFlags),
|
||||
dom.autoDispose(computedRule),
|
||||
dom.autoDispose(textColor),
|
||||
dom.autoDispose(fillColor),
|
||||
dom.autoDispose(zebraColor),
|
||||
dom.autoDispose(fontBold),
|
||||
dom.autoDispose(fontItalic),
|
||||
dom.autoDispose(fontUnderline),
|
||||
dom.autoDispose(fontStrikethrough),
|
||||
|
||||
// rowid dom
|
||||
dom('div.gridview_data_row_num',
|
||||
@@ -1159,6 +1195,13 @@ GridView.prototype.buildDom = function() {
|
||||
kd.toggleClass('record-add', row._isAddRow),
|
||||
kd.style('borderLeftWidth', v.borderWidthPx),
|
||||
kd.style('borderBottomWidth', v.borderWidthPx),
|
||||
kd.toggleClass('font-bold', fontBold),
|
||||
kd.toggleClass('font-underline', fontUnderline),
|
||||
kd.toggleClass('font-italic', fontItalic),
|
||||
kd.toggleClass('font-strikethrough', fontStrikethrough),
|
||||
kd.style('--grist-row-background-color', fillColor),
|
||||
kd.style('--grist-row-background-color-zebra', zebraColor),
|
||||
kd.style('--grist-row-color', textColor),
|
||||
//These are grabbed from v.optionsObj at start of GridView buildDom
|
||||
kd.toggleClass('record-hlines', vHorizontalGridlines),
|
||||
kd.toggleClass('record-vlines', vVerticalGridlines),
|
||||
@@ -1611,6 +1654,15 @@ GridView.prototype._duplicateRows = async function() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildStyleOption(owner, computedRule, optionName) {
|
||||
return ko.computed(() => {
|
||||
if (owner.isDisposed()) { return null; }
|
||||
const rule = computedRule();
|
||||
if (!rule || !rule.style) { return ''; }
|
||||
return rule.style[optionName] || '';
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to show tooltip over column selection in the full edit mode.
|
||||
class HoverColumnTooltip {
|
||||
constructor(el) {
|
||||
@@ -1631,4 +1683,20 @@ class HoverColumnTooltip {
|
||||
}
|
||||
}
|
||||
|
||||
// Simple function that calculates good color for zebra stripes.
|
||||
function calcZebra(hex) {
|
||||
if (!hex || hex.length !== 7) { return hex; }
|
||||
// HSL: [HUE, SATURATION, LIGHTNESS]
|
||||
const hsl = convert.hex.hsl(hex.substr(1));
|
||||
// For bright color, we will make it darker. Value was picked by hand, to
|
||||
// produce #f8f8f8f out of #ffffff.
|
||||
if (hsl[2] > 50) { hsl[2] -= 2.6; }
|
||||
// For darker color, we will make it brighter. Value was picked by hand to look
|
||||
// good for the darkest colors in our palette.
|
||||
else if (hsl[2] > 1) { hsl[2] += 11; }
|
||||
// For very dark colors
|
||||
else { hsl[2] += 16; }
|
||||
return `#${convert.hsl.hex(hsl)}`;
|
||||
}
|
||||
|
||||
module.exports = GridView;
|
||||
|
||||
@@ -798,7 +798,8 @@ export class Importer extends DisposableWithEvents {
|
||||
const editRow = vsi?.moveEditRowToCursor();
|
||||
const editorHolder = openFormulaEditor({
|
||||
gristDoc: this._gristDoc,
|
||||
field,
|
||||
column: field.column(),
|
||||
editingFormula: field.editingFormula,
|
||||
refElem,
|
||||
editRow,
|
||||
setupCleanup: this._setupFormulaEditorCleanup.bind(this),
|
||||
@@ -819,7 +820,7 @@ export class Importer extends DisposableWithEvents {
|
||||
* focus.
|
||||
*/
|
||||
private _setupFormulaEditorCleanup(
|
||||
owner: MultiHolder, _doc: GristDoc, field: ViewFieldRec, _saveEdit: () => Promise<unknown>
|
||||
owner: MultiHolder, _doc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
|
||||
) {
|
||||
const saveEdit = () => _saveEdit().catch(reportError);
|
||||
|
||||
@@ -828,7 +829,7 @@ export class Importer extends DisposableWithEvents {
|
||||
|
||||
owner.onDispose(() => {
|
||||
this.off('importer_focus', saveEdit);
|
||||
field.editingFormula(false);
|
||||
editingFormula(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {ColumnTransform} from 'app/client/components/ColumnTransform';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import * as TypeConversion from 'app/client/components/TypeConversion';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {cssButtonRow} from 'app/client/ui/RightPanel';
|
||||
import {cssButtonRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {testId} from 'app/client/ui2018/cssVars';
|
||||
import {FieldBuilder} from 'app/client/widgets/FieldBuilder';
|
||||
|
||||
@@ -14,7 +14,7 @@ const {addToSort} = require('app/client/lib/sortUtil');
|
||||
const {updatePositions} = require('app/client/lib/sortUtil');
|
||||
const {attachColumnFilterMenu} = require('app/client/ui/ColumnFilterMenu');
|
||||
const {addFilterMenu} = require('app/client/ui/FilterBar');
|
||||
const {cssIcon, cssRow} = require('app/client/ui/RightPanel');
|
||||
const {cssIcon, cssRow} = require('app/client/ui/RightPanelStyles');
|
||||
const {basicButton, primaryButton} = require('app/client/ui2018/buttons');
|
||||
const {labeledLeftSquareCheckbox} = require("app/client/ui2018/checkbox");
|
||||
const {colors} = require('app/client/ui2018/cssVars');
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
import ViewConfigTab from 'app/client/components/ViewConfigTab';
|
||||
import * as FieldConfig from 'app/client/ui/FieldConfig';
|
||||
export {ViewConfigTab, FieldConfig};
|
||||
export {ConditionalStyle} from 'app/client/widgets/ConditionalStyle';
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
selected fields - this still remains white.
|
||||
TODO: consider making this color the single source
|
||||
*/
|
||||
background: white;
|
||||
background: var(--grist-row-background-color, white);
|
||||
color: var(--grist-row-color, black);
|
||||
}
|
||||
|
||||
.record.record-hlines { /* Overwrites style, width set on element */
|
||||
@@ -27,7 +28,7 @@
|
||||
}
|
||||
|
||||
.record.record-zebra.record-even {
|
||||
background-color: #f8f8f8;
|
||||
background-color: var(--grist-row-background-color-zebra, #f8f8f8);
|
||||
}
|
||||
|
||||
.record.record-add {
|
||||
@@ -71,12 +72,12 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--grist-diff-background-color, var(--grist-cell-background-color, unset));
|
||||
--grist-actual-cell-color: var(--grist-diff-color, var(--grist-cell-color));
|
||||
color: var(--grist-actual-cell-color, black);
|
||||
--grist-actual-cell-color: var(--grist-diff-color, var(--grist-cell-color, var(--grist-row-color)));
|
||||
color: var(--grist-actual-cell-color, unset);
|
||||
}
|
||||
|
||||
.field.selected .field_clip {
|
||||
mix-blend-mode: darken;
|
||||
mix-blend-mode: luminosity;
|
||||
}
|
||||
|
||||
.field_clip.invalid, .field_clip.field-error-from-style {
|
||||
|
||||
41
app/client/models/RuleOwner.ts
Normal file
41
app/client/models/RuleOwner.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {ColumnRec, DocModel} from 'app/client/models/DocModel';
|
||||
import {Style} from 'app/client/models/Styles';
|
||||
import * as modelUtil from 'app/client/models/modelUtil';
|
||||
|
||||
export interface RuleOwner {
|
||||
// Field or Section can have a list of conditional styling rules. Each style is a combination of a formula and options
|
||||
// that must by applied. Style is persisted as a new hidden formula column and the list of such
|
||||
// columns is stored as Reference List property ('rules') in a field or column.
|
||||
tableId: ko.Computed<string>;
|
||||
// If this field (or column) has a list of conditional styling rules.
|
||||
hasRules: ko.Computed<boolean>;
|
||||
// List of columns that are used as rules for conditional styles.
|
||||
rulesCols: ko.Computed<ColumnRec[]>;
|
||||
// List of columns ids that are used as rules for conditional styles.
|
||||
rulesColsIds: ko.Computed<string[]>;
|
||||
// List of styles used by conditional rules.
|
||||
rulesStyles: modelUtil.KoSaveableObservable<Style[]>;
|
||||
// Adds empty conditional style rule. Sets before sending to the server.
|
||||
addEmptyRule(): Promise<void>;
|
||||
// Removes one rule from the collection. Removes before sending update to the server.
|
||||
removeRule(index: number): Promise<void>;
|
||||
}
|
||||
|
||||
export async function removeRule(docModel: DocModel, owner: RuleOwner, index: number) {
|
||||
const col = owner.rulesCols.peek()[index];
|
||||
if (!col) {
|
||||
throw new Error(`There is no rule at index ${index}`);
|
||||
}
|
||||
const newStyles = owner.rulesStyles.peek()?.slice() ?? [];
|
||||
if (newStyles.length >= index) {
|
||||
newStyles.splice(index, 1);
|
||||
} else {
|
||||
console.debug(`There are not style options at index ${index}`);
|
||||
}
|
||||
await docModel.docData.bundleActions("Remove conditional rule", () =>
|
||||
Promise.all([
|
||||
owner.rulesStyles.setAndSave(newStyles),
|
||||
docModel.docData.sendAction(['RemoveColumn', owner.tableId.peek(), col.colId.peek()])
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {ColumnRec, DocModel, IRowModel, refListRecords, refRecord, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {formatterForRec} from 'app/client/models/entities/ColumnRec';
|
||||
import * as modelUtil from 'app/client/models/modelUtil';
|
||||
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
|
||||
import {Style} from 'app/client/models/Styles';
|
||||
import * as UserType from 'app/client/widgets/UserType';
|
||||
import {DocumentSettings} from 'app/common/DocumentSettings';
|
||||
@@ -9,7 +10,7 @@ import {createParser} from 'app/common/ValueParser';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
// Represents a page entry in the tree of pages.
|
||||
export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
|
||||
export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, RuleOwner {
|
||||
viewSection: ko.Computed<ViewSectionRec>;
|
||||
widthDef: modelUtil.KoSaveableObservable<number>;
|
||||
|
||||
@@ -86,26 +87,6 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
|
||||
// `formatter` formats actual cell values, e.g. a whole list from the display column.
|
||||
formatter: ko.Computed<BaseFormatter>;
|
||||
|
||||
// Field can have a list of conditional styling rules. Each style is a combination of a formula and options
|
||||
// that must by applied to a field. Style is persisted as a new hidden formula column and the list of such
|
||||
// columns is stored as Reference List property ('rules') in a field or column.
|
||||
// Rule for conditional style is a formula of the hidden column, style options are saved as JSON object in
|
||||
// a styleOptions field (in that hidden formula column).
|
||||
|
||||
// If this field (or column) has a list of conditional styling rules.
|
||||
hasRules: ko.Computed<boolean>;
|
||||
// List of columns that are used as rules for conditional styles.
|
||||
rulesCols: ko.Computed<ColumnRec[]>;
|
||||
// List of columns ids that are used as rules for conditional styles.
|
||||
rulesColsIds: ko.Computed<string[]>;
|
||||
// List of styles used by conditional rules.
|
||||
rulesStyles: modelUtil.KoSaveableObservable<Style[]>;
|
||||
|
||||
// Adds empty conditional style rule. Sets before sending to the server.
|
||||
addEmptyRule(): Promise<void>;
|
||||
// Removes one rule from the collection. Removes before sending update to the server.
|
||||
removeRule(index: number): Promise<void>;
|
||||
|
||||
createValueParser(): (value: string) => any;
|
||||
|
||||
// Helper which adds/removes/updates field's displayCol to match the formula.
|
||||
@@ -253,6 +234,7 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
||||
return docModel.docData.bundleActions("Update choices configuration", callback, actionOptions);
|
||||
};
|
||||
|
||||
this.tableId = ko.pureComputed(() => this.column().table().tableId());
|
||||
this.rulesCols = refListRecords(docModel.columns, ko.pureComputed(() => this._fieldOrColumn().rules()));
|
||||
this.rulesColsIds = ko.pureComputed(() => this.rulesCols().map(c => c.colId()));
|
||||
this.rulesStyles = modelUtil.fieldWithDefault(
|
||||
@@ -274,25 +256,5 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
||||
await docModel.docData.sendAction(action, `Update rules for ${this.colId.peek()}`);
|
||||
};
|
||||
|
||||
// Helper method to remove a rule.
|
||||
this.removeRule = async (index: number) => {
|
||||
const col = this.rulesCols.peek()[index];
|
||||
if (!col) {
|
||||
throw new Error(`There is no rule at index ${index}`);
|
||||
}
|
||||
const tableData = docModel.dataTables[col.table.peek().tableId.peek()].tableData;
|
||||
const newStyles = this.rulesStyles.peek().slice();
|
||||
if (newStyles.length >= index) {
|
||||
newStyles.splice(index, 1);
|
||||
} else {
|
||||
console.debug(`There are not style options at index ${index}`);
|
||||
}
|
||||
const callback = () =>
|
||||
Promise.all([
|
||||
this.rulesStyles.setAndSave(newStyles),
|
||||
tableData.sendTableAction(['RemoveColumn', col.colId.peek()])
|
||||
]);
|
||||
const actionOptions = {nestInActiveBundle: this.column.peek().isTransforming.peek()};
|
||||
await docModel.docData.bundleActions("Remove conditional rule", callback, actionOptions);
|
||||
};
|
||||
this.removeRule = (index: number) => removeRule(docModel, this, index);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FilterRec,
|
||||
IRowModel,
|
||||
recordSet,
|
||||
refListRecords,
|
||||
refRecord,
|
||||
TableRec,
|
||||
ViewFieldRec,
|
||||
@@ -22,13 +23,14 @@ import {arrayRepeat} from 'app/common/gutil';
|
||||
import {Sort} from 'app/common/SortSpec';
|
||||
import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI';
|
||||
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
|
||||
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
|
||||
import {Computed, Holder, Observable} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
import defaults = require('lodash/defaults');
|
||||
|
||||
// Represents a section of user views, now also known as a "page widget" (e.g. a view may contain
|
||||
// a grid section and a chart section).
|
||||
export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
|
||||
export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleOwner {
|
||||
viewFields: ko.Computed<KoArray<ViewFieldRec>>;
|
||||
|
||||
// All table columns associated with this view section, excluding hidden helper columns.
|
||||
@@ -168,6 +170,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
|
||||
// List of selected rows
|
||||
selectedRows: Observable<number[]>;
|
||||
|
||||
editingFormula: ko.Computed<boolean>;
|
||||
|
||||
// Save all filters of fields/columns in the section.
|
||||
saveFilters(): Promise<void>;
|
||||
@@ -235,7 +238,12 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
||||
|
||||
// All table columns associated with this view section, excluding any hidden helper columns.
|
||||
this.columns = this.autoDispose(ko.pureComputed(() => this.table().columns().all().filter(c => !c.isHiddenCol())));
|
||||
|
||||
this.editingFormula = ko.pureComputed({
|
||||
read: () => docModel.editingFormula(),
|
||||
write: val => {
|
||||
docModel.editingFormula(val);
|
||||
}
|
||||
});
|
||||
const defaultOptions = {
|
||||
verticalGridlines: true,
|
||||
horizontalGridlines: true,
|
||||
@@ -586,4 +594,25 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
||||
|
||||
this.allowSelectBy = Observable.create(this, false);
|
||||
this.selectedRows = Observable.create(this, []);
|
||||
|
||||
this.tableId = ko.pureComputed(() => this.table().tableId());
|
||||
const rawSection = ko.pureComputed(() => this.table().rawViewSection());
|
||||
this.rulesCols = refListRecords(docModel.columns, ko.pureComputed(() => rawSection().rules()));
|
||||
this.rulesColsIds = ko.pureComputed(() => this.rulesCols().map(c => c.colId()));
|
||||
this.rulesStyles = modelUtil.savingComputed({
|
||||
read: () => rawSection().optionsObj.prop("rulesOptions")() ?? [],
|
||||
write: (setter, val) => setter(rawSection().optionsObj.prop("rulesOptions"), val)
|
||||
});
|
||||
this.hasRules = ko.pureComputed(() => this.rulesCols().length > 0);
|
||||
this.addEmptyRule = async () => {
|
||||
const action = [
|
||||
'AddEmptyRule',
|
||||
this.tableId.peek(),
|
||||
null,
|
||||
null
|
||||
];
|
||||
await docModel.docData.sendAction(action, `Update rules for ${this.table.peek().tableId.peek()}`);
|
||||
};
|
||||
|
||||
this.removeRule = (index: number) => removeRule(docModel, this, index);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import * as kf from 'app/client/lib/koForm';
|
||||
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
|
||||
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {cssLabel, cssRow, cssSeparator, cssSubLabel, cssTextInput} from 'app/client/ui/RightPanel';
|
||||
import {cssLabel, cssRow, cssSeparator} from 'app/client/ui/RightPanelStyles';
|
||||
import {cssDragRow, cssFieldEntry, cssFieldLabel} from 'app/client/ui/VisibleFieldsConfig';
|
||||
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||
import {cssDragger} from 'app/client/ui2018/draggableList';
|
||||
import {textInput} from 'app/client/ui2018/editableLabel';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
@@ -534,6 +535,13 @@ const cssRemoveIcon = styled(icon, `
|
||||
}
|
||||
`);
|
||||
|
||||
// Additional text in label (greyed out)
|
||||
const cssSubLabel = styled('span', `
|
||||
text-transform: none;
|
||||
font-size: ${vars.xsmallFontSize};
|
||||
color: ${colors.slate};
|
||||
`);
|
||||
|
||||
const cssAddMapping = styled('div', `
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
@@ -548,3 +556,13 @@ const cssAddMapping = styled('div', `
|
||||
--icon-color: ${colors.darkGreen};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssTextInput = styled(textInput, `
|
||||
flex: 1 0 auto;
|
||||
|
||||
&:disabled {
|
||||
color: ${colors.slate};
|
||||
background-color: ${colors.lightGrey};
|
||||
pointer-events: none;
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -2,7 +2,7 @@ import {CursorPos} from 'app/client/components/Cursor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
|
||||
import {cssBlockedCursor, cssEmptySeparator, cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas';
|
||||
import {textButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||
@@ -382,3 +382,7 @@ const cssColTieConnectors = styled('div', `
|
||||
border-left: none;
|
||||
z-index: -1;
|
||||
`);
|
||||
|
||||
const cssEmptySeparator = styled('div', `
|
||||
margin-top: 16px;
|
||||
`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ViewSectionRec } from "app/client/models/DocModel";
|
||||
import { KoSaveableObservable, setSaveValue } from "app/client/models/modelUtil";
|
||||
import { cssLabel, cssRow } from "app/client/ui/RightPanel";
|
||||
import { cssLabel, cssRow } from "app/client/ui/RightPanelStyles";
|
||||
import { squareCheckbox } from "app/client/ui2018/checkbox";
|
||||
import { testId } from "app/client/ui2018/cssVars";
|
||||
import { Computed, Disposable, dom, IDisposableOwner, styled } from "grainjs";
|
||||
|
||||
@@ -335,6 +335,17 @@ export class RightPanel extends Disposable {
|
||||
return dom.create(GridOptions, activeSection);
|
||||
}),
|
||||
|
||||
domComputed((use) => {
|
||||
if (use(this._pageWidgetType) !== 'record') { return null; }
|
||||
return [
|
||||
cssSeparator(),
|
||||
cssLabel('ROW STYLE'),
|
||||
domAsync(imports.loadViewPane().then(ViewPane =>
|
||||
dom.create(ViewPane.ConditionalStyle, "Row Style", activeSection, this._gristDoc)
|
||||
))
|
||||
];
|
||||
}),
|
||||
|
||||
dom.maybe((use) => use(this._pageWidgetType) === 'chart', () => [
|
||||
cssLabel('CHART TYPE'),
|
||||
vct._buildChartConfigDom(),
|
||||
@@ -572,20 +583,14 @@ const cssBottomText = styled('span', `
|
||||
padding: 4px 16px;
|
||||
`);
|
||||
|
||||
export const cssLabel = styled('div', `
|
||||
const cssLabel = styled('div', `
|
||||
text-transform: uppercase;
|
||||
margin: 16px 16px 12px 16px;
|
||||
font-size: ${vars.xsmallFontSize};
|
||||
`);
|
||||
|
||||
// Additional text in label (greyed out)
|
||||
export const cssSubLabel = styled('span', `
|
||||
text-transform: none;
|
||||
font-size: ${vars.xsmallFontSize};
|
||||
color: ${colors.slate};
|
||||
`);
|
||||
|
||||
export const cssRow = styled('div', `
|
||||
const cssRow = styled('div', `
|
||||
display: flex;
|
||||
margin: 8px 16px;
|
||||
align-items: center;
|
||||
@@ -597,13 +602,8 @@ export const cssRow = styled('div', `
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssBlockedCursor = styled('span', `
|
||||
&, & * {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssButtonRow = styled(cssRow, `
|
||||
const cssButtonRow = styled(cssRow, `
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
& > button {
|
||||
@@ -611,7 +611,7 @@ export const cssButtonRow = styled(cssRow, `
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssIcon = styled(icon, `
|
||||
const cssIcon = styled(icon, `
|
||||
flex: 0 0 auto;
|
||||
--icon-color: ${colors.slate};
|
||||
`);
|
||||
@@ -669,7 +669,7 @@ const cssHoverIcon = styled(icon, `
|
||||
background-color: vars(--icon-color);
|
||||
`);
|
||||
|
||||
export const cssSubTabContainer = styled('div', `
|
||||
const cssSubTabContainer = styled('div', `
|
||||
height: 48px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -677,7 +677,7 @@ export const cssSubTabContainer = styled('div', `
|
||||
justify-content: space-between;
|
||||
`);
|
||||
|
||||
export const cssSubTab = styled('div', `
|
||||
const cssSubTab = styled('div', `
|
||||
color: ${colors.lightGreen};
|
||||
flex: auto;
|
||||
height: 100%;
|
||||
@@ -709,15 +709,11 @@ const cssTabContents = styled('div', `
|
||||
overflow: auto;
|
||||
`);
|
||||
|
||||
export const cssSeparator = styled('div', `
|
||||
const cssSeparator = styled('div', `
|
||||
border-bottom: 1px solid ${colors.mediumGrey};
|
||||
margin-top: 16px;
|
||||
`);
|
||||
|
||||
export const cssEmptySeparator = styled('div', `
|
||||
margin-top: 16px;
|
||||
`);
|
||||
|
||||
const cssConfigContainer = styled('div', `
|
||||
overflow: auto;
|
||||
--color-list-item: none;
|
||||
@@ -765,7 +761,7 @@ const cssListItem = styled('li', `
|
||||
padding: 4px 8px;
|
||||
`);
|
||||
|
||||
export const cssTextInput = styled(textInput, `
|
||||
const cssTextInput = styled(textInput, `
|
||||
flex: 1 0 auto;
|
||||
|
||||
&:disabled {
|
||||
|
||||
45
app/client/ui/RightPanelStyles.ts
Normal file
45
app/client/ui/RightPanelStyles.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {styled} from 'grainjs';
|
||||
|
||||
export const cssIcon = styled(icon, `
|
||||
flex: 0 0 auto;
|
||||
--icon-color: ${colors.slate};
|
||||
`);
|
||||
|
||||
export const cssLabel = styled('div', `
|
||||
text-transform: uppercase;
|
||||
margin: 16px 16px 12px 16px;
|
||||
font-size: ${vars.xsmallFontSize};
|
||||
`);
|
||||
|
||||
export const cssRow = styled('div', `
|
||||
display: flex;
|
||||
margin: 8px 16px;
|
||||
align-items: center;
|
||||
&-top-space {
|
||||
margin-top: 24px;
|
||||
}
|
||||
&-disabled {
|
||||
color: ${colors.slate};
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssBlockedCursor = styled('span', `
|
||||
&, & * {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssButtonRow = styled(cssRow, `
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
& > button {
|
||||
margin-left: 16px;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssSeparator = styled('div', `
|
||||
border-bottom: 1px solid ${colors.mediumGrey};
|
||||
margin-top: 16px;
|
||||
`);
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
import type {TableRec} from 'app/client/models/entities/TableRec';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {cssRow} from 'app/client/ui/RightPanel';
|
||||
import {cssRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||
import {basicButton, primaryButton} from "app/client/ui2018/buttons";
|
||||
import {labeledSquareCheckbox} from "app/client/ui2018/checkbox";
|
||||
|
||||
@@ -19,19 +19,20 @@ export interface StyleOptions {
|
||||
}
|
||||
|
||||
export class ColorOption {
|
||||
constructor(
|
||||
public color: Observable<string|undefined>,
|
||||
// If the color accepts undefined/empty as a value. Controls empty selector in the picker.
|
||||
public allowsNone: boolean = false,
|
||||
// Default color to show when value is empty or undefined (itself can be empty).
|
||||
public defaultColor: string = '',
|
||||
// Text to be shown in the picker when color is not set.
|
||||
public noneText: string = '',
|
||||
// Preview color to show when value is undefined.
|
||||
public previewNoneColor: string = '',) {
|
||||
if (defaultColor && allowsNone) {
|
||||
throw new Error("Allowing an empty value is not compatible with a default color");
|
||||
}
|
||||
public color: Observable<string|undefined>;
|
||||
// If the color accepts undefined/empty as a value. Controls empty selector in the picker.
|
||||
public allowsNone: boolean = false;
|
||||
// Default color to show when value is empty or undefined (itself can be empty).
|
||||
public defaultColor: string = '';
|
||||
// Text to be shown in the picker when color is not set.
|
||||
public noneText: string = '';
|
||||
constructor(options: {
|
||||
color: Observable<string|undefined>,
|
||||
allowsNone?: boolean,
|
||||
defaultColor?: string,
|
||||
noneText?: string
|
||||
}) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +54,8 @@ export function colorSelect(
|
||||
cssContent(
|
||||
cssButtonIcon(
|
||||
'T',
|
||||
dom.style('color', use => use(textColor.color) || textColor.previewNoneColor),
|
||||
dom.style('background-color', (use) => use(fillColor.color)?.slice(0, 7) || fillColor.previewNoneColor),
|
||||
dom.style('color', use => use(textColor.color) || textColor.defaultColor),
|
||||
dom.style('background-color', (use) => use(fillColor.color)?.slice(0, 7) || fillColor.defaultColor),
|
||||
dom.cls('font-bold', use => use(styleOptions.fontBold) ?? false),
|
||||
dom.cls('font-italic', use => use(styleOptions.fontItalic) ?? false),
|
||||
dom.cls('font-underline', use => use(styleOptions.fontUnderline) ?? false),
|
||||
@@ -80,8 +81,8 @@ export function colorButton(
|
||||
const { textColor, fillColor } = styleOptions;
|
||||
const iconBtn = cssIconBtn(
|
||||
'T',
|
||||
dom.style('color', use => use(textColor.color) || textColor.previewNoneColor),
|
||||
dom.style('background-color', (use) => use(fillColor.color)?.slice(0, 7) || fillColor.previewNoneColor),
|
||||
dom.style('color', use => use(textColor.color) || textColor.defaultColor),
|
||||
dom.style('background-color', (use) => use(fillColor.color)?.slice(0, 7) || fillColor.defaultColor),
|
||||
dom.cls('font-bold', use => use(styleOptions.fontBold) ?? false),
|
||||
dom.cls('font-italic', use => use(styleOptions.fontItalic) ?? false),
|
||||
dom.cls('font-underline', use => use(styleOptions.fontUnderline) ?? false),
|
||||
@@ -228,8 +229,6 @@ interface PickerComponentOptions {
|
||||
defaultColor: string;
|
||||
// Text to be shown in the picker when color is not set.
|
||||
noneText: string;
|
||||
// Preview color to show when value is undefined.
|
||||
previewNoneColor: string;
|
||||
}
|
||||
class PickerComponent extends Disposable {
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {editableLabel} from 'app/client/ui2018/editableLabel';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {IModalControl, modal} from 'app/client/ui2018/modals';
|
||||
import {renderFileType} from 'app/client/widgets/AttachmentsWidget';
|
||||
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||
import {FieldOptions, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
|
||||
import {CellValue} from 'app/common/DocActions';
|
||||
import {SingleCell} from 'app/common/TableData';
|
||||
import {clamp, encodeQueryParams} from 'app/common/gutil';
|
||||
@@ -56,7 +56,7 @@ export class AttachmentsEditor extends NewBaseEditor {
|
||||
private _index: LiveIndex;
|
||||
private _selected: Computed<Attachment|null>;
|
||||
|
||||
constructor(options: Options) {
|
||||
constructor(options: FieldOptions) {
|
||||
super(options);
|
||||
|
||||
const docData: DocData = options.gristDoc.docData;
|
||||
|
||||
@@ -3,7 +3,7 @@ import {Computed, dom, fromKo, input, makeTestId, onElem, styled, TestId} from '
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {dragOverClass} from 'app/client/lib/dom';
|
||||
import {selectFiles, uploadFiles} from 'app/client/lib/uploads';
|
||||
import {cssRow} from 'app/client/ui/RightPanel';
|
||||
import {cssRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||
import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
|
||||
import {encodeQueryParams} from 'app/common/gutil';
|
||||
|
||||
@@ -1,265 +1,88 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ColumnRec} from 'app/client/models/DocModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
||||
import {Style} from 'app/client/models/Styles';
|
||||
import {cssFieldFormula} from 'app/client/ui/FieldConfig';
|
||||
import {cssIcon, cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {textButton} from 'app/client/ui2018/buttons';
|
||||
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {setupEditorCleanup} from 'app/client/widgets/FieldEditor';
|
||||
import {cssError, openFormulaEditor} from 'app/client/widgets/FormulaEditor';
|
||||
import {isRaisedException, isValidRuleValue} from 'app/common/gristTypes';
|
||||
import {RowRecord} from 'app/plugin/GristData';
|
||||
import {Computed, Disposable, dom, DomContents, fromKo, makeTestId, MultiHolder, Observable, styled} from 'grainjs';
|
||||
import debounce = require('lodash/debounce');
|
||||
|
||||
const testId = makeTestId('test-widget-style-');
|
||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||
import {ConditionalStyle} from 'app/client/widgets/ConditionalStyle';
|
||||
import {Disposable, dom, DomContents, fromKo, MultiHolder, Observable, styled} from 'grainjs';
|
||||
|
||||
export class CellStyle extends Disposable {
|
||||
protected textColor: Observable<string|undefined>;
|
||||
protected fillColor: Observable<string|undefined>;
|
||||
protected fontBold: Observable<boolean|undefined>;
|
||||
protected fontUnderline: Observable<boolean|undefined>;
|
||||
protected fontItalic: Observable<boolean|undefined>;
|
||||
protected fontStrikethrough: Observable<boolean|undefined>;
|
||||
// Holds data from currently selected record (holds data only when this field has conditional styles).
|
||||
protected currentRecord: Computed<RowRecord | undefined>;
|
||||
// Helper field for refreshing current record data.
|
||||
protected dataChangeTrigger = Observable.create(this, 0);
|
||||
private _textColor: Observable<string|undefined>;
|
||||
private _fillColor: Observable<string|undefined>;
|
||||
private _fontBold: Observable<boolean|undefined>;
|
||||
private _fontUnderline: Observable<boolean|undefined>;
|
||||
private _fontItalic: Observable<boolean|undefined>;
|
||||
private _fontStrikethrough: Observable<boolean|undefined>;
|
||||
|
||||
constructor(
|
||||
protected field: ViewFieldRec,
|
||||
protected gristDoc: GristDoc,
|
||||
protected defaultTextColor: string
|
||||
private _field: ViewFieldRec,
|
||||
private _gristDoc: GristDoc,
|
||||
private _defaultTextColor: string
|
||||
) {
|
||||
super();
|
||||
this.textColor = fromKo(this.field.textColor);
|
||||
this.fillColor = fromKo(this.field.fillColor);
|
||||
this.fontBold = fromKo(this.field.fontBold);
|
||||
this.fontUnderline = fromKo(this.field.fontUnderline);
|
||||
this.fontItalic = fromKo(this.field.fontItalic);
|
||||
this.fontStrikethrough = fromKo(this.field.fontStrikethrough);
|
||||
this.currentRecord = Computed.create(this, use => {
|
||||
if (!use(this.field.hasRules)) {
|
||||
return;
|
||||
}
|
||||
// As we are not subscribing to data change, we will monitor actions
|
||||
// that are sent from the server to refresh this computed observable.
|
||||
void use(this.dataChangeTrigger);
|
||||
const tableId = use(use(use(field.column).table).tableId);
|
||||
const tableData = gristDoc.docData.getTable(tableId)!;
|
||||
const cursor = use(gristDoc.cursorPosition);
|
||||
// Make sure we are not on the new row.
|
||||
if (!cursor || typeof cursor.rowId !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
return tableData.getRecord(cursor.rowId);
|
||||
});
|
||||
|
||||
// Here we will subscribe to tableActionEmitter, and update currentRecord observable.
|
||||
// We have 'dataChangeTrigger' that is just a number that will be updated every time
|
||||
// we received some table actions.
|
||||
const debouncedUpdate = debounce(() => {
|
||||
if (this.dataChangeTrigger.isDisposed()) {
|
||||
return;
|
||||
}
|
||||
this.dataChangeTrigger.set(this.dataChangeTrigger.get() + 1);
|
||||
}, 0);
|
||||
Computed.create(this, (use) => {
|
||||
const tableId = use(use(use(field.column).table).tableId);
|
||||
const tableData = gristDoc.docData.getTable(tableId);
|
||||
return tableData ? use.owner.autoDispose(tableData.tableActionEmitter.addListener(debouncedUpdate)) : null;
|
||||
});
|
||||
this._textColor = fromKo(this._field.textColor);
|
||||
this._fillColor = fromKo(this._field.fillColor);
|
||||
this._fontBold = fromKo(this._field.fontBold);
|
||||
this._fontUnderline = fromKo(this._field.fontUnderline);
|
||||
this._fontItalic = fromKo(this._field.fontItalic);
|
||||
this._fontStrikethrough = fromKo(this._field.fontStrikethrough);
|
||||
}
|
||||
|
||||
public buildDom(): DomContents {
|
||||
const holder = new MultiHolder();
|
||||
return [
|
||||
cssLabel('CELL STYLE', dom.autoDispose(holder)),
|
||||
cssLine(
|
||||
cssLabel('CELL STYLE', dom.autoDispose(holder)),
|
||||
cssButton('Open row styles', dom.on('click', allCommands.viewTabOpen.run)),
|
||||
),
|
||||
cssRow(
|
||||
colorSelect(
|
||||
{
|
||||
textColor: new ColorOption(this.textColor, false, this.defaultTextColor),
|
||||
fillColor: new ColorOption(this.fillColor, true, '', 'none', '#FFFFFF'),
|
||||
fontBold: this.fontBold,
|
||||
fontItalic: this.fontItalic,
|
||||
fontUnderline: this.fontUnderline,
|
||||
fontStrikethrough: this.fontStrikethrough
|
||||
textColor: new ColorOption(
|
||||
{ color: this._textColor, defaultColor: this._defaultTextColor, noneText: 'default'}
|
||||
),
|
||||
fillColor: new ColorOption(
|
||||
{ color: this._fillColor, allowsNone: true, noneText: 'none'}
|
||||
),
|
||||
fontBold: this._fontBold,
|
||||
fontItalic: this._fontItalic,
|
||||
fontUnderline: this._fontUnderline,
|
||||
fontStrikethrough: this._fontStrikethrough
|
||||
},
|
||||
// Calling `field.widgetOptionsJson.save()` saves both fill and text color settings.
|
||||
() => this.field.widgetOptionsJson.save()
|
||||
() => this._field.widgetOptionsJson.save()
|
||||
)
|
||||
),
|
||||
cssRow(
|
||||
{style: 'margin-top: 16px'},
|
||||
textButton(
|
||||
'Add conditional style',
|
||||
testId('add-conditional-style'),
|
||||
dom.on('click', () => this.field.addEmptyRule())
|
||||
),
|
||||
dom.hide(this.field.hasRules)
|
||||
),
|
||||
dom.domComputedOwned(
|
||||
use => use(this.field.rulesCols),
|
||||
(owner, rules) =>
|
||||
cssRuleList(
|
||||
dom.show(rules.length > 0),
|
||||
...rules.map((column, ruleIndex) => {
|
||||
const textColor = this._buildStyleOption(owner, ruleIndex, 'textColor');
|
||||
const fillColor = this._buildStyleOption(owner, ruleIndex, 'fillColor');
|
||||
const fontBold = this._buildStyleOption(owner, ruleIndex, 'fontBold');
|
||||
const fontItalic = this._buildStyleOption(owner, ruleIndex, 'fontItalic');
|
||||
const fontUnderline = this._buildStyleOption(owner, ruleIndex, 'fontUnderline');
|
||||
const fontStrikethrough = this._buildStyleOption(owner, ruleIndex, 'fontStrikethrough');
|
||||
const save = async () => {
|
||||
// This will save both options.
|
||||
await this.field.rulesStyles.save();
|
||||
};
|
||||
const currentValue = Computed.create(owner, use => {
|
||||
const record = use(this.currentRecord);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const value = record[use(column.colId)];
|
||||
return value ?? null;
|
||||
});
|
||||
const hasError = Computed.create(owner, use => {
|
||||
return !isValidRuleValue(use(currentValue));
|
||||
});
|
||||
const errorMessage = Computed.create(owner, use => {
|
||||
const value = use(currentValue);
|
||||
return (!use(hasError) ? '' :
|
||||
isRaisedException(value) ? 'Error in style rule' :
|
||||
'Rule must return True or False');
|
||||
});
|
||||
return dom('div',
|
||||
testId(`conditional-rule-${ruleIndex}`),
|
||||
testId(`conditional-rule`), // for testing
|
||||
cssLineLabel('IF...'),
|
||||
cssColumnsRow(
|
||||
cssLeftColumn(
|
||||
this._buildRuleFormula(column.formula, column, hasError),
|
||||
cssRuleError(
|
||||
dom.text(errorMessage),
|
||||
dom.show(hasError),
|
||||
testId(`rule-error-${ruleIndex}`),
|
||||
),
|
||||
colorSelect(
|
||||
{
|
||||
textColor: new ColorOption(textColor, true, '', 'default'),
|
||||
fillColor: new ColorOption(fillColor, true, '', 'none'),
|
||||
fontBold,
|
||||
fontItalic,
|
||||
fontUnderline,
|
||||
fontStrikethrough
|
||||
},
|
||||
save,
|
||||
'Cell style'
|
||||
)
|
||||
),
|
||||
cssRemoveButton(
|
||||
'Remove',
|
||||
testId(`remove-rule-${ruleIndex}`),
|
||||
dom.on('click', () => this.field.removeRule(ruleIndex))
|
||||
)
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
),
|
||||
cssRow(
|
||||
textButton('Add another rule'),
|
||||
testId('add-another-rule'),
|
||||
dom.on('click', () => this.field.addEmptyRule()),
|
||||
dom.show(this.field.hasRules)
|
||||
),
|
||||
dom.create(ConditionalStyle, "Cell Style", this._field, this._gristDoc)
|
||||
];
|
||||
}
|
||||
|
||||
private _buildStyleOption<T extends keyof Style>(owner: Disposable, index: number, option: T) {
|
||||
const obs = Computed.create(owner, use => use(this.field.rulesStyles)[index]?.[option]);
|
||||
obs.onWrite(value => {
|
||||
const list = Array.from(this.field.rulesStyles.peek() ?? []);
|
||||
list[index] = list[index] ?? {};
|
||||
list[index][option] = value as any;
|
||||
this.field.rulesStyles(list);
|
||||
});
|
||||
return obs;
|
||||
}
|
||||
|
||||
private _buildRuleFormula(
|
||||
formula: KoSaveableObservable<string>,
|
||||
column: ColumnRec,
|
||||
hasError: Observable<boolean>
|
||||
) {
|
||||
return cssFieldFormula(
|
||||
formula,
|
||||
{maxLines: 1},
|
||||
dom.cls('formula_field_sidepane'),
|
||||
dom.cls(cssErrorBorder.className, hasError),
|
||||
{tabIndex: '-1'},
|
||||
dom.on('focus', (_, refElem) => {
|
||||
const vsi = this.gristDoc.viewModel.activeSection().viewInstance();
|
||||
const editorHolder = openFormulaEditor({
|
||||
gristDoc: this.gristDoc,
|
||||
field: this.field,
|
||||
column,
|
||||
editRow: vsi?.moveEditRowToCursor(),
|
||||
refElem,
|
||||
setupCleanup: setupEditorCleanup,
|
||||
});
|
||||
// Add editor to document holder - this will prevent multiple formula editor instances.
|
||||
this.gristDoc.fieldEditorHolder.autoDispose(editorHolder);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cssRemoveButton = styled(cssIcon, `
|
||||
flex: none;
|
||||
margin: 6px;
|
||||
margin-right: 0px;
|
||||
transform: translateY(4px);
|
||||
cursor: pointer;
|
||||
--icon-color: ${colors.slate};
|
||||
&:hover {
|
||||
--icon-color: ${colors.lightGreen};
|
||||
const 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;
|
||||
`);
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
position: relative;
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
background-color: var(--grist-cell-color, #606060);
|
||||
border: 1px solid var(--grist-cell-color, #606060);
|
||||
background-color: var(--grist-actual-cell-color, #606060);
|
||||
border: 1px solid var(--grist-actual-cell-color, #606060);
|
||||
left: 3px;
|
||||
top: -5px;
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
position: relative;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background-color: var(--grist-cell-color, #606060);
|
||||
border: 1px solid var(--grist-cell-color, #606060);
|
||||
background-color: var(--grist-actual-cell-color, #606060);
|
||||
border: 1px solid var(--grist-actual-cell-color, #606060);
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ CheckBox.prototype.buildConfigDom = function() {
|
||||
|
||||
CheckBox.prototype.buildDom = function(row) {
|
||||
var value = row[this.field.colId()];
|
||||
console.log(this);
|
||||
return dom('div.field_clip',
|
||||
dom('div.widget_checkbox',
|
||||
dom.on('click', () => {
|
||||
@@ -28,14 +29,8 @@ CheckBox.prototype.buildDom = function(row) {
|
||||
}),
|
||||
dom('div.widget_checkmark',
|
||||
kd.show(value),
|
||||
dom('div.checkmark_kick',
|
||||
kd.style('background-color', this.field.textColor),
|
||||
kd.style('border-color', this.field.textColor)
|
||||
),
|
||||
dom('div.checkmark_stem',
|
||||
kd.style('background-color', this.field.textColor),
|
||||
kd.style('border-color', this.field.textColor)
|
||||
)
|
||||
dom('div.checkmark_kick'),
|
||||
dom('div.checkmark_stem')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||
import {menuCssClass} from 'app/client/ui2018/menus';
|
||||
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
|
||||
import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
|
||||
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||
import {FieldOptions, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
|
||||
import {csvEncodeRow} from 'app/common/csvFormat';
|
||||
import {CellValue} from "app/common/DocActions";
|
||||
import {decodeObject, encodeObject} from 'app/plugin/objtypes';
|
||||
@@ -31,9 +31,9 @@ export class ChoiceListEditor extends NewBaseEditor {
|
||||
private _tokenField: TokenField<ChoiceItem>;
|
||||
private _textInput: HTMLInputElement;
|
||||
private _dom: HTMLElement;
|
||||
private _editorPlacement: EditorPlacement;
|
||||
private _editorPlacement!: EditorPlacement;
|
||||
private _contentSizer: HTMLElement; // Invisible element to size the editor with all the tokens
|
||||
private _inputSizer: HTMLElement; // Part of _contentSizer to size the text input
|
||||
private _inputSizer!: HTMLElement; // Part of _contentSizer to size the text input
|
||||
private _alignment: string;
|
||||
|
||||
// Whether to include a button to show a new choice.
|
||||
@@ -43,7 +43,7 @@ export class ChoiceListEditor extends NewBaseEditor {
|
||||
|
||||
private _choiceOptionsByName: ChoiceOptions;
|
||||
|
||||
constructor(options: Options) {
|
||||
constructor(protected options: FieldOptions) {
|
||||
super(options);
|
||||
|
||||
const choices: string[] = options.field.widgetOptionsJson.peek().choices || [];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {IToken, TokenField} from 'app/client/lib/TokenField';
|
||||
import {cssBlockedCursor} from 'app/client/ui/RightPanel';
|
||||
import {cssBlockedCursor} from 'app/client/ui/RightPanelStyles';
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {colorButton, ColorOption} from 'app/client/ui2018/ColorSelect';
|
||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||
@@ -298,8 +298,9 @@ export class ChoiceListEntry extends Disposable {
|
||||
dom.autoDispose(textColorObs),
|
||||
dom.autoDispose(choiceText),
|
||||
colorButton({
|
||||
textColor: new ColorOption(textColorObs, false, '#000000'),
|
||||
fillColor: new ColorOption(fillColorObs, true, '', 'none', '#FFFFFF'),
|
||||
textColor: new ColorOption({color: textColorObs, defaultColor: '#000000'}),
|
||||
fillColor: new ColorOption(
|
||||
{color: fillColorObs, allowsNone: true, noneText: 'none', defaultColor: '#FFFFFF'}),
|
||||
fontBold: fontBoldObs,
|
||||
fontItalic: fontItalicObs,
|
||||
fontUnderline: fontUnderlineObs,
|
||||
|
||||
@@ -2,7 +2,7 @@ import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
||||
import {Style} from 'app/client/models/Styles';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {testId} from 'app/client/ui2018/cssVars';
|
||||
import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry';
|
||||
import {choiceToken, DEFAULT_FILL_COLOR, DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';
|
||||
|
||||
266
app/client/widgets/ConditionalStyle.ts
Normal file
266
app/client/widgets/ConditionalStyle.ts
Normal 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;
|
||||
`);
|
||||
@@ -8,7 +8,7 @@ var AbstractWidget = require('./AbstractWidget');
|
||||
|
||||
const {fromKoSave} = require('app/client/lib/fromKoSave');
|
||||
const {alignmentSelect} = require('app/client/ui2018/buttonSelect');
|
||||
const {cssRow, cssLabel} = require('app/client/ui/RightPanel');
|
||||
const {cssRow, cssLabel} = require('app/client/ui/RightPanelStyles');
|
||||
const {cssTextInput} = require("app/client/ui2018/editableLabel");
|
||||
const {styled, fromKo} = require('grainjs');
|
||||
const {select} = require('app/client/ui2018/menus');
|
||||
|
||||
@@ -10,7 +10,7 @@ var gutil = require('app/common/gutil');
|
||||
|
||||
const {fromKoSave} = require('app/client/lib/fromKoSave');
|
||||
const {alignmentSelect} = require('app/client/ui2018/buttonSelect');
|
||||
const {cssRow, cssLabel} = require('app/client/ui/RightPanel');
|
||||
const {cssRow, cssLabel} = require('app/client/ui/RightPanelStyles');
|
||||
const {cssTextInput} = require("app/client/ui2018/editableLabel");
|
||||
const {dom: gdom, styled, fromKo} = require('grainjs');
|
||||
const {select} = require('app/client/ui2018/menus');
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ColumnRec, DocModel, ViewFieldRec } from 'app/client/models/DocModel';
|
||||
import { SaveableObjObservable, setSaveValue } from 'app/client/models/modelUtil';
|
||||
import { CombinedStyle, Style } from 'app/client/models/Styles';
|
||||
import { FieldSettingsMenu } from 'app/client/ui/FieldMenus';
|
||||
import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanel';
|
||||
import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanelStyles';
|
||||
import { buttonSelect } from 'app/client/ui2018/buttonSelect';
|
||||
import { colors } from 'app/client/ui2018/cssVars';
|
||||
import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
|
||||
@@ -497,7 +497,7 @@ export class FieldBuilder extends Disposable {
|
||||
// If user set white color - remove it to play nice with zebra strips.
|
||||
// If there is no color we are using fully transparent white color (for tests mainly).
|
||||
fill = fill ? fill.toUpperCase() : fill;
|
||||
return (fill === '#FFFFFF' ? '' : fill) || '#FFFFFF00';
|
||||
return (fill === '#FFFFFF' ? '' : fill) || '';
|
||||
})).onlyNotifyUnequal();
|
||||
|
||||
const fontBold = buildFontOptions(this, computedRule, 'fontBold');
|
||||
@@ -622,7 +622,8 @@ export class FieldBuilder extends Disposable {
|
||||
onCancel?: () => void) {
|
||||
const editorHolder = openFormulaEditor({
|
||||
gristDoc: this.gristDoc,
|
||||
field: this.field,
|
||||
column: this.field.column(),
|
||||
editingFormula: this.field.editingFormula,
|
||||
setupCleanup: setupEditorCleanup,
|
||||
editRow,
|
||||
refElem,
|
||||
|
||||
@@ -161,7 +161,7 @@ export class FieldEditor extends Disposable {
|
||||
|
||||
// for readonly field we don't need to do anything special
|
||||
if (!options.readonly) {
|
||||
setupEditorCleanup(this, this._gristDoc, this._field, this._saveEdit);
|
||||
setupEditorCleanup(this, this._gristDoc, this._field.editingFormula, this._saveEdit);
|
||||
} else {
|
||||
setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit());
|
||||
}
|
||||
@@ -203,6 +203,8 @@ export class FieldEditor extends Disposable {
|
||||
const editor = this._editorHolder.autoDispose(editorCtor.create({
|
||||
gristDoc: this._gristDoc,
|
||||
field: this._field,
|
||||
column: this._field.column(), // needed for FormulaEditor
|
||||
editingFormula: this._field.editingFormula, // needed for Formula editor
|
||||
cellValue,
|
||||
rowId: this._editRow.id(),
|
||||
formulaError: error,
|
||||
@@ -394,7 +396,7 @@ function setupReadonlyEditorCleanup(
|
||||
* - Arrange for UnsavedChange protection against leaving the page with unsaved changes.
|
||||
*/
|
||||
export function setupEditorCleanup(
|
||||
owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, _saveEdit: () => Promise<unknown>
|
||||
owner: MultiHolder, gristDoc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
|
||||
) {
|
||||
const saveEdit = () => _saveEdit().catch(reportError);
|
||||
|
||||
@@ -408,6 +410,6 @@ export function setupEditorCleanup(
|
||||
owner.onDispose(() => {
|
||||
gristDoc.app.off('clipboard_focus', saveEdit);
|
||||
// Unset field.editingFormula flag when the editor closes.
|
||||
field.editingFormula(false);
|
||||
editingFormula(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ const minFormulaErrorWidth = 400;
|
||||
|
||||
export interface IFormulaEditorOptions extends Options {
|
||||
cssClass?: string;
|
||||
editingFormula?: ko.Computed<boolean>,
|
||||
editingFormula: ko.Computed<boolean>,
|
||||
column: ColumnRec,
|
||||
}
|
||||
|
||||
|
||||
@@ -42,12 +43,12 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
private _formulaEditor: any;
|
||||
private _commandGroup: any;
|
||||
private _dom: HTMLElement;
|
||||
private _editorPlacement: EditorPlacement;
|
||||
private _editorPlacement!: EditorPlacement;
|
||||
|
||||
constructor(options: IFormulaEditorOptions) {
|
||||
super(options);
|
||||
|
||||
const editingFormula = options.editingFormula || options.field.editingFormula;
|
||||
const editingFormula = options.editingFormula;
|
||||
|
||||
const initialValue = undef(options.state as string | undefined, options.editValue, String(options.cellValue));
|
||||
// create editor state observable (used by draft and latest position memory)
|
||||
@@ -56,7 +57,7 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
this._formulaEditor = AceEditor.create({
|
||||
// A bit awkward, but we need to assume calcSize is not used until attach() has been called
|
||||
// and _editorPlacement created.
|
||||
field: options.field,
|
||||
column: options.column,
|
||||
calcSize: this._calcSize.bind(this),
|
||||
gristDoc: options.gristDoc,
|
||||
saveValueOnBlurEvent: !options.readonly,
|
||||
@@ -129,7 +130,7 @@ export class FormulaEditor extends NewBaseEditor {
|
||||
aceObj.gotoLine(0, 0); // By moving, ace editor won't highlight anything
|
||||
}
|
||||
// This catches any change to the value including e.g. via backspace or paste.
|
||||
aceObj.once("change", () => editingFormula(true));
|
||||
aceObj.once("change", () => editingFormula?.(true));
|
||||
})
|
||||
),
|
||||
(options.formulaError ? [
|
||||
@@ -268,9 +269,10 @@ function _isInIdentifier(line: string, column: number) {
|
||||
*/
|
||||
export function openFormulaEditor(options: {
|
||||
gristDoc: GristDoc,
|
||||
field: ViewFieldRec,
|
||||
// Associated formula from a different column (for example style rule).
|
||||
column?: ColumnRec,
|
||||
field?: ViewFieldRec,
|
||||
editingFormula?: ko.Computed<boolean>,
|
||||
// Needed to get exception value, if any.
|
||||
editRow?: DataRowModel,
|
||||
// Element over which to position the editor.
|
||||
@@ -282,13 +284,17 @@ export function openFormulaEditor(options: {
|
||||
setupCleanup: (
|
||||
owner: MultiHolder,
|
||||
doc: GristDoc,
|
||||
field: ViewFieldRec,
|
||||
editingFormula: ko.Computed<boolean>,
|
||||
save: () => Promise<void>
|
||||
) => void,
|
||||
}): Disposable {
|
||||
const {gristDoc, field, editRow, refElem, setupCleanup} = options;
|
||||
const {gristDoc, editRow, refElem, setupCleanup} = options;
|
||||
const holder = MultiHolder.create(null);
|
||||
const column = options.column ? options.column : field.origCol();
|
||||
const column = options.column ?? options.field?.column();
|
||||
|
||||
if (!column) {
|
||||
throw new Error('Column or field is required');
|
||||
}
|
||||
|
||||
// AsyncOnce ensures it's called once even if triggered multiple times.
|
||||
const saveEdit = asyncOnce(async () => {
|
||||
@@ -316,7 +322,8 @@ export function openFormulaEditor(options: {
|
||||
// Replace the item in the Holder with a new one, disposing the previous one.
|
||||
const editor = FormulaEditor.create(holder, {
|
||||
gristDoc,
|
||||
field,
|
||||
column,
|
||||
editingFormula: options.editingFormula,
|
||||
rowId: editRow ? editRow.id() : 0,
|
||||
cellValue: column.formula(),
|
||||
formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
|
||||
@@ -325,17 +332,23 @@ export function openFormulaEditor(options: {
|
||||
commands: editCommands,
|
||||
cssClass: 'formula_editor_sidepane',
|
||||
readonly : false
|
||||
});
|
||||
} as IFormulaEditorOptions);
|
||||
editor.attach(refElem);
|
||||
|
||||
const editingFormula = options.editingFormula ?? options?.field?.editingFormula;
|
||||
|
||||
if (!editingFormula) {
|
||||
throw new Error('editingFormula is required');
|
||||
}
|
||||
|
||||
// When formula is empty enter formula-editing mode (highlight formula icons; click on a column inserts its ID).
|
||||
// This function is used for primarily for switching between different column behaviors, so we want to enter full
|
||||
// edit mode right away.
|
||||
// TODO: consider converting it to parameter, when this will be used in different scenarios.
|
||||
if (!column.formula()) {
|
||||
field.editingFormula(true);
|
||||
editingFormula(true);
|
||||
}
|
||||
setupCleanup(holder, gristDoc, field, saveEdit);
|
||||
setupCleanup(holder, gristDoc, editingFormula, saveEdit);
|
||||
return holder;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Options} from 'app/client/widgets/NewBaseEditor';
|
||||
import {FieldOptions} from 'app/client/widgets/NewBaseEditor';
|
||||
import {NTextEditor} from 'app/client/widgets/NTextEditor';
|
||||
|
||||
/**
|
||||
@@ -6,7 +6,7 @@ import {NTextEditor} from 'app/client/widgets/NTextEditor';
|
||||
* to the user how links should be formatted.
|
||||
*/
|
||||
export class HyperLinkEditor extends NTextEditor {
|
||||
constructor(options: Options) {
|
||||
constructor(options: FieldOptions) {
|
||||
super(options);
|
||||
this.textInput.setAttribute('placeholder', '[link label] url');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fromKoSave } from 'app/client/lib/fromKoSave';
|
||||
import { findLinks } from 'app/client/lib/textUtils';
|
||||
import { DataRowModel } from 'app/client/models/DataRowModel';
|
||||
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
|
||||
import { cssRow } from 'app/client/ui/RightPanel';
|
||||
import { cssRow } from 'app/client/ui/RightPanelStyles';
|
||||
import { alignmentSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect';
|
||||
import { colors, testId } from 'app/client/ui2018/cssVars';
|
||||
import { cssIconBackground, icon } from 'app/client/ui2018/icons';
|
||||
|
||||
@@ -5,7 +5,7 @@ import {createGroup} from 'app/client/components/commands';
|
||||
import {testId} from 'app/client/ui2018/cssVars';
|
||||
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
|
||||
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
|
||||
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||
import {FieldOptions, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
|
||||
import {CellValue} from "app/common/DocActions";
|
||||
import {undef} from 'app/common/gutil';
|
||||
import {dom, Observable} from 'grainjs';
|
||||
@@ -19,13 +19,13 @@ export class NTextEditor extends NewBaseEditor {
|
||||
protected commandGroup: any;
|
||||
|
||||
private _dom: HTMLElement;
|
||||
private _editorPlacement: EditorPlacement;
|
||||
private _editorPlacement!: EditorPlacement;
|
||||
private _contentSizer: HTMLElement;
|
||||
private _alignment: string;
|
||||
|
||||
// Note: TextEditor supports also options.placeholder for use by derived classes, but this is
|
||||
// easy to apply to this.textInput without needing a separate option.
|
||||
constructor(options: Options) {
|
||||
constructor(protected options: FieldOptions) {
|
||||
super(options);
|
||||
|
||||
const initialValue: string = undef(
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface IEditorCommandGroup {
|
||||
|
||||
export interface Options {
|
||||
gristDoc: GristDoc;
|
||||
field: ViewFieldRec;
|
||||
cellValue: CellValue;
|
||||
rowId: number;
|
||||
formulaError?: Observable<CellValue>;
|
||||
@@ -26,6 +25,10 @@ export interface Options {
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
export interface FieldOptions extends Options {
|
||||
field: ViewFieldRec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Required parameters:
|
||||
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
|
||||
@@ -42,7 +45,7 @@ export abstract class NewBaseEditor extends Disposable {
|
||||
* updated to new-style Disposables.
|
||||
*/
|
||||
public static create<Opt extends Options>(owner: IDisposableOwner|null, options: Opt): NewBaseEditor;
|
||||
public static create(options: Options): NewBaseEditor;
|
||||
public static create<Opt extends Options>(options: Opt): NewBaseEditor;
|
||||
public static create(ownerOrOptions: any, options?: any): NewBaseEditor {
|
||||
return options ?
|
||||
Disposable.create.call(this as any, ownerOrOptions, options) :
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
|
||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {IOptionFull, select} from 'app/client/ui2018/menus';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { reportError } from 'app/client/models/errors';
|
||||
import { colors, testId, vars } from 'app/client/ui2018/cssVars';
|
||||
import { icon } from 'app/client/ui2018/icons';
|
||||
import { menuCssClass } from 'app/client/ui2018/menus';
|
||||
import { Options } from 'app/client/widgets/NewBaseEditor';
|
||||
import { FieldOptions } from 'app/client/widgets/NewBaseEditor';
|
||||
import { NTextEditor } from 'app/client/widgets/NTextEditor';
|
||||
import { nocaseEqual, ReferenceUtils } from 'app/client/lib/ReferenceUtils';
|
||||
import { undef } from 'app/common/gutil';
|
||||
@@ -21,7 +21,7 @@ export class ReferenceEditor extends NTextEditor {
|
||||
private _autocomplete?: Autocomplete<ICellItem>;
|
||||
private _utils: ReferenceUtils;
|
||||
|
||||
constructor(options: Options) {
|
||||
constructor(options: FieldOptions) {
|
||||
super(options);
|
||||
|
||||
const docData = options.gristDoc.docData;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { menuCssClass } from 'app/client/ui2018/menus';
|
||||
import { cssChoiceToken } from 'app/client/widgets/ChoiceToken';
|
||||
import { createMobileButtons, getButtonMargins } from 'app/client/widgets/EditorButtons';
|
||||
import { EditorPlacement } from 'app/client/widgets/EditorPlacement';
|
||||
import { NewBaseEditor, Options } from 'app/client/widgets/NewBaseEditor';
|
||||
import { FieldOptions, NewBaseEditor } from 'app/client/widgets/NewBaseEditor';
|
||||
import { cssRefList, renderACItem } from 'app/client/widgets/ReferenceEditor';
|
||||
import { ReferenceUtils } from 'app/client/lib/ReferenceUtils';
|
||||
import { csvEncodeRow } from 'app/common/csvFormat';
|
||||
@@ -46,13 +46,13 @@ export class ReferenceListEditor extends NewBaseEditor {
|
||||
private _tokenField: TokenField<ReferenceItem>;
|
||||
private _textInput: HTMLInputElement;
|
||||
private _dom: HTMLElement;
|
||||
private _editorPlacement: EditorPlacement;
|
||||
private _editorPlacement!: EditorPlacement;
|
||||
private _contentSizer: HTMLElement; // Invisible element to size the editor with all the tokens
|
||||
private _inputSizer: HTMLElement; // Part of _contentSizer to size the text input
|
||||
private _inputSizer!: HTMLElement; // Part of _contentSizer to size the text input
|
||||
private _alignment: string;
|
||||
private _utils: ReferenceUtils;
|
||||
|
||||
constructor(options: Options) {
|
||||
constructor(protected options: FieldOptions) {
|
||||
super(options);
|
||||
|
||||
const docData = options.gristDoc.docData;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
|
||||
.switch_on > .switch_slider {
|
||||
background-color: var(--grist-cell-color, #2CB0AF);
|
||||
background-color: var(--grist-actual-cell-color, #2CB0AF);
|
||||
}
|
||||
|
||||
.switch_on > .switch_circle {
|
||||
|
||||
Reference in New Issue
Block a user