(core) Multi-column configuration

Summary:
Creator panel allows now to edit multiple columns at once
for some options that are common for them. Options that
are not common are disabled.

List of options that can be edited for multiple columns:
- Column behavior (but limited to empty/formula columns)
- Alignment and wrapping
- Default style
- Number options (for numeric columns)
- Column types (but only for empty/formula columns)

If multiple columns of the same type are selected, most of
the options are available to change, except formula, trigger formula
and conditional styles.

Editing column label or column id is disabled by default for multiple
selection.

Not related: some tests were fixed due to the change in the column label
and id widget in grist-core (disabled attribute was replaced by readonly).

Test Plan: Updated and new tests.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3598
pull/322/head
Jarosław Sadziński 2 years ago
parent ab3cdb62ac
commit 8be920dd25

@ -183,6 +183,9 @@ function BaseView(gristDoc, viewSectionModel, options) {
this.fieldBuilders.at(this.cursor.fieldIndex())
));
// By default, a view doesn't support selectedColumns, but it can be overridden.
this.selectedColumns = null;
// Observable for whether the data in this view is truncated, i.e. not all rows are included
// (this can only be true for on-demand tables).
this.isTruncated = ko.observable(false);

@ -86,6 +86,16 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
this.cellSelector, this.tableModel.tableData, this.sortedRows, this.viewSection.viewFields);
this.colMenuTargets = {}; // Reference from column ref to its menu target dom
this.selectedColumns = this.autoDispose(ko.pureComputed(() => {
const result = this.viewSection.viewFields().all().filter((field, index) => {
// During column removal or restoring (with undo), some columns fields
// might be disposed.
if (field.isDisposed() || field.column().isDisposed()) { return false; }
return this.cellSelector.containsCol(index);
});
return result;
}));
// Cache of column right offsets, used to determine the col select range
this.colRightOffsets = this.autoDispose(ko.computed(() => {
let fields = this.viewSection.viewFields();

@ -12,8 +12,6 @@ declare module "app/client/lib/browserGlobals";
declare module "app/client/lib/dom";
declare module "app/client/lib/koDom";
declare module "app/client/lib/koForm";
declare module "app/client/widgets/UserType";
declare module "app/client/widgets/UserTypeImpl";
// tslint:disable:max-classes-per-file
@ -38,7 +36,7 @@ declare module "app/client/components/BaseView" {
import {DataRowModel} from 'app/client/models/DataRowModel';
import {LazyArrayModel} from "app/client/models/DataTableModel";
import DataTableModel from "app/client/models/DataTableModel";
import {ViewSectionRec} from "app/client/models/DocModel";
import {ViewFieldRec, ViewSectionRec} from "app/client/models/DocModel";
import {FilterInfo} from 'app/client/models/entities/ViewSectionRec';
import {SortedRowSet} from 'app/client/models/rowset';
import {FieldBuilder} from "app/client/widgets/FieldBuilder";
@ -59,6 +57,7 @@ declare module "app/client/components/BaseView" {
public cursor: Cursor;
public sortedRows: SortedRowSet;
public activeFieldBuilder: ko.Computed<FieldBuilder>;
public selectedColumns: ko.Computed<ViewFieldRec[]>|null;
public disableEditing: ko.Computed<boolean>;
public isTruncated: ko.Observable<boolean>;
public tableModel: DataTableModel;
@ -178,6 +177,7 @@ declare module "app/client/models/modelUtil" {
prop(propName: string): KoSaveableObservable<any>;
}
function objObservable<T>(obs: ko.KoSaveableObservable<T>): SaveableObjObservable<T>;
function objObservable<T>(obs: ko.Observable<T>): ObjObservable<T>;
function jsonObservable(obs: KoSaveableObservable<string>,
modifierFunc?: any, optContext?: any): SaveableObjObservable<any>;

@ -122,9 +122,9 @@ exports.setComputedErrorHandler = setComputedErrorHandler;
/**
* Returns an observable which mirrors the passed-in argument, but returns a default value if the
* underlying field is falsy. Writes to the returned observable translate directly to writes to the
* underlying one. The default may be a function, evaluated as for computed observables,
* with optContext as the context.
* underlying field is falsy and has non-boolean type. Writes to the returned observable translate
* directly to writes to the underlying one. The default may be a function, evaluated as for computed
* observables, with optContext as the context.
*/
function observableWithDefault(obs, defaultOrFunc, optContext) {
if (typeof defaultOrFunc !== 'function') {
@ -132,7 +132,13 @@ function observableWithDefault(obs, defaultOrFunc, optContext) {
defaultOrFunc = function() { return def; };
}
return ko.pureComputed({
read: function() { return obs() || defaultOrFunc.call(this); },
read: function() {
const value = obs();
if (typeof value === 'boolean') {
return value;
}
return value || defaultOrFunc.call(this);
},
write: function(val) { obs(val); },
owner: optContext
});

@ -1,10 +1,10 @@
export interface Style {
textColor?: string;
fillColor?: string;
fontBold?: boolean;
fontUnderline?: boolean;
fontItalic?: boolean;
fontStrikethrough?: boolean;
textColor?: string|undefined; // this can be string, undefined or an absent key.
fillColor?: string|undefined;
fontBold?: boolean|undefined;
fontUnderline?: boolean|undefined;
fontItalic?: boolean|undefined;
fontStrikethrough?: boolean|undefined;
}
export class CombinedStyle implements Style {

@ -0,0 +1,312 @@
import * as modelUtil from 'app/client/models/modelUtil';
// This is circular import, but only for types so it's fine.
import type {DocModel, ViewFieldRec} from 'app/client/models/DocModel';
import {Style} from 'app/client/models/Styles';
import * as UserType from 'app/client/widgets/UserType';
import {ifNotSet} from 'app/common/gutil';
import * as ko from 'knockout';
import intersection from "lodash/intersection";
import isEqual from "lodash/isEqual";
export class ViewFieldConfig {
/** If there are multiple columns selected in the viewSection */
public multiselect: ko.Computed<boolean>;
/** If all selected columns have the same widget list. */
public sameWidgets: ko.Computed<boolean>;
/** Widget options for a field or multiple fields */
public options: CommonOptions;
// Rest of the options mimic the same options from ViewFieldRec.
public wrap: modelUtil.KoSaveableObservable<boolean|undefined>;
public widget: ko.Computed<string|undefined>;
public alignment: modelUtil.KoSaveableObservable<string|undefined>;
public textColor: modelUtil.KoSaveableObservable<string|undefined>;
public fillColor: modelUtil.KoSaveableObservable<string|undefined>;
public fontBold: modelUtil.KoSaveableObservable<boolean|undefined>;
public fontUnderline: modelUtil.KoSaveableObservable<boolean|undefined>;
public fontItalic: modelUtil.KoSaveableObservable<boolean|undefined>;
public fontStrikethrough: modelUtil.KoSaveableObservable<boolean|undefined>;
private _fields: ko.PureComputed<ViewFieldRec[]>;
constructor(private _field: ViewFieldRec, private _docModel: DocModel) {
// Everything here will belong to a _field, this class is just a builder.
const owner = _field;
// Get all selected fields from the viewSection, if there is only one field
// selected (or the selection is empty) return it in an array.
this._fields = owner.autoDispose(ko.pureComputed(() => {
const list = this._field.viewSection().selectedFields();
if (!list || !list.length) {
return [_field];
}
// Make extra sure that field and column is not disposed, most of the knockout
// based entities, don't dispose their computed observables. As we keep references
// for them, it can happen that some of them are disposed while we are still
// computing something (mainly when columns are removed or restored using undo).
return list.filter(f => !f.isDisposed() && !f.column().isDisposed());
}));
// Just a helper field to see if we have multiple selected columns or not.
this.multiselect = owner.autoDispose(ko.pureComputed(() => this._fields().length > 1));
// Calculate if all columns share the same allowed widget list (like for Numeric type
// we have normal TextBox and Spinner). This will be used to allow the user to change
// this type if such columns are selected.
this.sameWidgets = owner.autoDispose(ko.pureComputed(() => {
const list = this._fields();
// If we have only one field selected, list is always the same.
if (list.length <= 1) { return true; }
// Now get all widget list and calculate intersection of the Sets.
// Widget types are just strings defined in UserType.
const widgets = list.map(c =>
Object.keys(UserType.typeDefs[c.column().pureType()]?.widgets ?? {})
);
return intersection(...widgets).length === widgets[0]?.length;
}));
// Changing widget type is not trivial, as we need to carefully reset all
// widget options to their default values, and there is a nuance there.
this.widget = owner.autoDispose(ko.pureComputed({
read: () => {
// For single column, just return its widget type.
if (!this.multiselect()) {
return this._field.widget();
}
// If all have the same value, return it, otherwise
// return a default value for this option "undefined"
const values = this._fields().map(f => f.widget());
if (allSame(values)) {
return values[0];
} else {
return undefined;
}
},
write: (widget) => {
// Go through all the fields, and reset them all.
for(const field of this._fields.peek()) {
// Reset the entire JSON, so that all options revert to their defaults.
const previous = field.widgetOptionsJson.peek();
// We don't need to bundle anything (actions send in the same tick, are bundled
// by default).
field.widgetOptionsJson.setAndSave({
widget,
// Persists color settings across widgets (note: we cannot use `field.fillColor` to get the
// current value because it returns a default value for `undefined`. Same for `field.textColor`.
fillColor: previous.fillColor,
textColor: previous.textColor,
}).catch(reportError);
}
}
}));
// Calculate common options for all column types (and their widgets).
// We will use this, to know which options are allowed to be changed
// when multiple columns are selected.
const commonOptions = owner.autoDispose(ko.pureComputed(() => {
const fields = this._fields();
// Put all options of first widget in the Set, and then remove
// them one by one, if they are not present in other fields.
let options: Set<string>|null = null;
for(const field of fields) {
// First get the data, and prepare initial set.
const widget = field.widget() || '';
const widgetOptions = UserType.typeDefs[field.column().pureType()]?.widgets[widget]?.options;
if (!widgetOptions) { continue; }
if (!options) { options = new Set(Object.keys(widgetOptions)); }
else {
// And now remove options that are not common.
const newOptions = new Set(Object.keys(widgetOptions));
for(const key of options) {
if (!newOptions.has(key)) {
options.delete(key);
}
}
}
}
// Add cell style options, as they are common to all widgets.
const result = options ?? new Set();
result.add('textColor');
result.add('fillColor');
result.add('fontBold');
result.add('fontItalic');
result.add('fontUnderline');
result.add('fontStrikethrough');
result.add('rulesOptions');
// We are leaving rules out for this moment, as this is not supported
// at this moment.
return result;
}));
// Prepare our "multi" widgetOptionsJson, that can read and save
// options for multiple columns.
const options = modelUtil.savingComputed({
read: () => {
// For one column, just proxy this to the field.
if (!this.multiselect()) {
return this._field.widgetOptionsJson();
}
// Assemble final json object.
const result: any = {};
// First get all widgetOption jsons from all columns/fields.
const optionList = this._fields().map(f => f.widgetOptionsJson());
// And fill only those that are common
const common = commonOptions();
for(const key of common) {
// Setting null means that this options is there, but has no value.
result[key] = null;
// If all columns have the same value, use it.
if (allSame(optionList.map(v => v[key]))) {
result[key] = optionList[0][key] ?? null;
}
}
return result;
},
write: (setter, value) => {
if (!this.multiselect.peek()) {
return setter(this._field.widgetOptionsJson, value);
}
// When the creator panel is saving widgetOptions, it will pass
// our virtual widgetObject, which has nulls for mixed values.
// If this option wasn't changed (set), we don't want to save it.
value = {...value};
for(const key of Object.keys(value)) {
if (value[key] === null) {
delete value[key];
}
}
// Now update all options, for all fields, be amending the options
// object from the field/column.
for(const item of this._fields.peek()) {
const previous = item.widgetOptionsJson.peek();
setter(item.widgetOptionsJson, {
...previous,
...value,
});
}
}
});
// We need some additional information about each property.
this.options = owner.autoDispose(extendObservable(modelUtil.objObservable(options), {
// Property is not supported by set of columns if it is not a common option.
disabled: prop => ko.pureComputed(() => !commonOptions().has(prop)),
// Property has mixed value, if no all options are the same.
mixed: prop => ko.pureComputed(() => !allSame(this._fields().map(f => f.widgetOptionsJson.prop(prop)()))),
// Property has empty value, if all options are empty (are null, undefined, empty Array or empty Object).
empty: prop => ko.pureComputed(() => allEmpty(this._fields().map(f => f.widgetOptionsJson.prop(prop)()))),
}));
// This is repeated logic for wrap property in viewFieldRec,
// every field has wrapping implicitly set to true on a card view.
this.wrap = modelUtil.fieldWithDefault(
this.options.prop('wrap'),
() => this._field.viewSection().parentKey() !== 'record'
);
// Some options extracted from our "virtual" widgetOptions json. Just to make
// them easier to use in the rest of the app.
this.alignment = this.options.prop('alignment');
this.textColor = this.options.prop('textColor');
this.fillColor = this.options.prop('fillColor');
this.fontBold = this.options.prop('fontBold');
this.fontUnderline = this.options.prop('fontUnderline');
this.fontItalic = this.options.prop('fontItalic');
this.fontStrikethrough = this.options.prop('fontStrikethrough');
}
// Helper for Choice/ChoiceList columns, that saves widget options and renames values in a document
// in one bundle
public async updateChoices(renames: Record<string, string>, options: any){
const hasRenames = !!Object.entries(renames).length;
const tableId = this._field.column.peek().table.peek().tableId.peek();
if (this.multiselect.peek()) {
this._field.config.options.update(options);
const colIds = this._fields.peek().map(f => f.colId.peek());
return this._docModel.docData.bundleActions("Update choices configuration", () => Promise.all([
this._field.config.options.save(),
!hasRenames ? null : this._docModel.docData.sendActions(
colIds.map(colId => ["RenameChoices", tableId, colId, renames])
)
]));
} else {
const column = this._field.column.peek();
// In case this column is being transformed - using Apply Formula to Data, bundle the action
// together with the transformation.
const actionOptions = {nestInActiveBundle: column.isTransforming.peek()};
this._field.widgetOptionsJson.update(options);
return this._docModel.docData.bundleActions("Update choices configuration", () => Promise.all([
this._field.widgetOptionsJson.save(),
!hasRenames ? null
: this._docModel.docData.sendAction(["RenameChoices", tableId, column.colId.peek(), renames])
]), actionOptions);
}
}
// Two helper methods, to support reverting viewFields style on the style
// picker. Style picker is reverting options by remembering what was
// there previously, and setting it back when user presses the cancel button.
// This won't work for mixed values, as there is no previous single value.
// To support this reverting mechanism, we will remember all styles for
// selected fields, and revert them ourselves. Style picker will either use
// our methods or fallback with its default behavior.
public copyStyles() {
return this._fields.peek().map(f => f.style.peek());
}
public setStyles(styles: Style[]|null) {
if (!styles) {
return;
}
for(let i = 0; i < this._fields.peek().length; i++) {
const f = this._fields.peek()[i];
f.style(styles[i]);
}
}
}
/**
* Deeply checks that all elements in a list are equal. Equality is checked by first
* converting "empty like" elements to null and then deeply comparing the elements.
*/
function allSame(arr: any[]) {
if (arr.length <= 1) { return true; }
const first = ifNotSet(arr[0], null);
const same = arr.every(next => {
return isEqual(ifNotSet(next, null), first);
});
return same;
}
/**
* Checks if every item in a list is empty (empty like in empty string, null, undefined, empty Array or Object)
*/
function allEmpty(arr: any[]) {
if (arr.length === 0) { return true; }
return arr.every(item => ifNotSet(item, null) === null);
}
type CommonOptions = modelUtil.SaveableObjObservable<any> & {
disabled(prop: string): ko.Computed<boolean>,
mixed(prop: string): ko.Computed<boolean>,
empty(prop: string): ko.Computed<boolean>,
}
// This is helper that adds disabled computed to an ObjObservable, it follows
// the same pattern as `prop` helper.
function extendObservable(
obs: modelUtil.SaveableObjObservable<any>,
options: { [key: string]: (prop: string) => ko.PureComputed<boolean> }
): CommonOptions {
const result = obs as any;
for(const key of Object.keys(options)) {
const cacheKey = `__${key}`;
result[cacheKey] = new Map();
result[key] = (prop: string) => {
if (!result[cacheKey].has(prop)) {
result[cacheKey].set(prop, options[key](prop));
}
return result[cacheKey].get(prop);
};
}
return result;
}

@ -11,6 +11,9 @@ import {
} from 'app/common/ValueFormatter';
import * as ko from 'knockout';
// Column behavior type, used primarily in the UI.
export type BEHAVIOR = "empty"|"formula"|"data";
// Represents a column in a user-defined table.
export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
table: ko.Computed<TableRec>;
@ -38,6 +41,9 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
// Convenience observable to obtain and set the type with no suffix
pureType: ko.Computed<string>;
// Column behavior as seen by the user.
behavior: ko.Computed<BEHAVIOR>;
// The column's display column
_displayColModel: ko.Computed<ColumnRec>;
@ -132,6 +138,8 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
this.visibleColFormatter = ko.pureComputed(() => formatterForRec(this, this, docModel, 'vcol'));
this.formatter = ko.pureComputed(() => formatterForRec(this, this, docModel, 'full'));
this.behavior = ko.pureComputed(() => this.isEmpty() ? 'empty' : this.isFormula() ? 'formula' : 'data');
}
export function formatterForRec(

@ -3,6 +3,7 @@ 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 {ViewFieldConfig} from 'app/client/models/ViewFieldConfig';
import * as UserType from 'app/client/widgets/UserType';
import {DocumentSettings} from 'app/common/DocumentSettings';
import {BaseFormatter} from 'app/common/ValueFormatter';
@ -61,18 +62,23 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, R
// which takes into account the default options for column's type.
widgetOptionsJson: modelUtil.SaveableObjObservable<any>;
// Whether lines should wrap in a cell.
wrapping: ko.Computed<boolean>;
disableModify: ko.Computed<boolean>;
disableEditData: ko.Computed<boolean>;
// Whether lines should wrap in a cell.
wrap: modelUtil.KoSaveableObservable<boolean>;
widget: modelUtil.KoSaveableObservable<string|undefined>;
textColor: modelUtil.KoSaveableObservable<string|undefined>;
fillColor: modelUtil.KoSaveableObservable<string|undefined>;
fontBold: modelUtil.KoSaveableObservable<boolean|undefined>;
fontUnderline: modelUtil.KoSaveableObservable<boolean|undefined>;
fontItalic: modelUtil.KoSaveableObservable<boolean|undefined>;
fontStrikethrough: modelUtil.KoSaveableObservable<boolean|undefined>;
// Helper computed to change style of a cell without saving it.
style: ko.PureComputed<Style>;
config: ViewFieldConfig;
documentSettings: ko.PureComputed<DocumentSettings>;
@ -91,10 +97,6 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, R
// Helper which adds/removes/updates field's displayCol to match the formula.
saveDisplayFormula(formula: string): Promise<void>|undefined;
// Helper for Choice/ChoiceList columns, that saves widget options and renames values in a document
// in one bundle
updateChoices(renameMap: Record<string, string>, options: any): Promise<void>;
}
export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void {
@ -147,17 +149,17 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
// Whether this field uses column's widgetOptions (true) or its own (false).
// During transform, use the transform column's options (which should be initialized to match
// field or column when the transform starts TODO).
this.useColOptions = ko.pureComputed(() => !this.widgetOptions() || this.column().isTransforming());
this.useColOptions = this.autoDispose(ko.pureComputed(() => !this.widgetOptions() || this.column().isTransforming()));
// Helper that returns the RowModel for either this field or its column, depending on
// useColOptions. Field and Column have a few identical fields:
// .widgetOptions() // JSON string of options
// .saveDisplayFormula() // Method to save the display formula
// .displayCol() // Reference to an optional associated display column.
this._fieldOrColumn = ko.pureComputed(() => this.useColOptions() ? this.column() : this);
this._fieldOrColumn = this.autoDispose(ko.pureComputed(() => this.useColOptions() ? this.column() : this));
// Display col ref to use for the field, defaulting to the plain column itself.
this.displayColRef = ko.pureComputed(() => this._fieldOrColumn().displayCol() || this.colRef());
this.displayColRef = this.autoDispose(ko.pureComputed(() => this._fieldOrColumn().displayCol() || this.colRef()));
this.visibleColRef = modelUtil.addSaveInterface(ko.pureComputed({
read: () => this._fieldOrColumn().visibleCol(),
@ -189,26 +191,23 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
};
// The widgetOptions to read and write: either the column's or the field's own.
this._widgetOptionsStr = modelUtil.savingComputed({
this._widgetOptionsStr = this.autoDispose(modelUtil.savingComputed({
read: () => this._fieldOrColumn().widgetOptions(),
write: (setter, val) => setter(this._fieldOrColumn().widgetOptions, val)
});
}));
// Observable for the object with the current options, either for the field or for the column,
// which takes into account the default options for this column's type.
this.widgetOptionsJson = modelUtil.jsonObservable(this._widgetOptionsStr,
(opts: any) => UserType.mergeOptions(opts || {}, this.column().pureType()));
this.wrapping = ko.pureComputed(() => {
// When user has yet to specify a desired wrapping state, we use different defaults for
// GridView (no wrap) and DetailView (wrap).
// "??" is the newish "nullish coalescing" operator. How cool is that!
return this.widgetOptionsJson().wrap ?? (this.viewSection().parentKey() !== 'record');
});
this.disableModify = ko.pureComputed(() => this.column().disableModify());
this.disableEditData = ko.pureComputed(() => this.column().disableEditData());
this.widgetOptionsJson = this.autoDispose(modelUtil.jsonObservable(this._widgetOptionsStr,
(opts: any) => UserType.mergeOptions(opts || {}, this.column().pureType())));
// When user has yet to specify a desired wrapping state, we use different defaults for
// GridView (no wrap) and DetailView (wrap).
this.wrap = this.autoDispose(modelUtil.fieldWithDefault(
this.widgetOptionsJson.prop('wrap'),
() => this.viewSection().parentKey() !== 'record'
));
this.widget = this.widgetOptionsJson.prop('widget');
this.textColor = this.widgetOptionsJson.prop('textColor');
this.fillColor = this.widgetOptionsJson.prop('fillColor');
this.fontBold = this.widgetOptionsJson.prop('fontBold');
@ -217,22 +216,19 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
this.fontStrikethrough = this.widgetOptionsJson.prop('fontStrikethrough');
this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson());
this.updateChoices = async (renames, widgetOptions) => {
// In case this column is being transformed - using Apply Formula to Data, bundle the action
// together with the transformation.
const actionOptions = {nestInActiveBundle: this.column.peek().isTransforming.peek()};
const hasRenames = !!Object.entries(renames).length;
const callback = async () => {
await Promise.all([
this.widgetOptionsJson.setAndSave(widgetOptions),
hasRenames ?
docModel.docData.sendAction(["RenameChoices", this.column().table().tableId(), this.colId(), renames]) :
null
]);
};
return docModel.docData.bundleActions("Update choices configuration", callback, actionOptions);
};
this.style = ko.pureComputed({
read: () => ({
textColor: this.textColor(),
fillColor: this.fillColor(),
fontBold: this.fontBold(),
fontUnderline: this.fontUnderline(),
fontItalic: this.fontItalic(),
fontStrikethrough: this.fontStrikethrough(),
}) as Style,
write: (style: Style) => {
this.widgetOptionsJson.update(style);
},
});
this.tableId = ko.pureComputed(() => this.column().table().tableId());
this.rulesCols = refListRecords(docModel.columns, ko.pureComputed(() => this._fieldOrColumn().rules()));
@ -257,4 +253,10 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
};
this.removeRule = (index: number) => removeRule(docModel, this, index);
// Externalize widgetOptions configuration, to support changing those options
// for multiple fields at once.
this.config = new ViewFieldConfig(this, docModel);
this.disableModify = this.autoDispose(ko.pureComputed(() => this.column().disableModify()));
this.disableEditData = this.autoDispose(ko.pureComputed(() => this.column().disableEditData()));
}

@ -23,6 +23,7 @@ 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 {BEHAVIOR} from 'app/client/models/entities/ColumnRec';
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
import {Computed, Holder, Observable} from 'grainjs';
import * as ko from 'knockout';
@ -172,6 +173,18 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
editingFormula: ko.Computed<boolean>;
// Selected fields (columns) for the section.
selectedFields: ko.Observable<ViewFieldRec[]>;
// Some computed observables for multi-select, used in the creator panel, by more than one widgets.
// Common column behavior or mixed.
columnsBehavior: ko.PureComputed<BEHAVIOR|'mixed'>;
// If all selected columns are empty or formula column.
columnsAllIsFormula: ko.PureComputed<boolean>;
// Common type of selected columns or mixed.
columnsType: ko.PureComputed<string|'mixed'>;
// Save all filters of fields/columns in the section.
saveFilters(): Promise<void>;
@ -277,6 +290,25 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
sectionId: customDefObj.prop('sectionId')
};
this.selectedFields = ko.observable<any>([]);
// During schema change, some columns/fields might be disposed beyond our control.
const selectedColumns = this.autoDispose(ko.pureComputed(() => this.selectedFields()
.filter(f => !f.isDisposed())
.map(f => f.column())
.filter(c => !c.isDisposed())));
this.columnsBehavior = ko.pureComputed(() => {
const list = new Set(selectedColumns().map(c => c.behavior()));
return list.size === 1 ? list.values().next().value : 'mixed';
});
this.columnsType = ko.pureComputed(() => {
const list = new Set(selectedColumns().map(c => c.type()));
return list.size === 1 ? list.values().next().value : 'mixed';
});
this.columnsAllIsFormula = ko.pureComputed(() => {
return selectedColumns().every(c => c.isFormula());
});
this.activeCustomOptions = modelUtil.customValue(this.customDef.widgetOptions);
this.saveCustomDef = async () => {

@ -1,6 +1,6 @@
import {CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas';
@ -16,7 +16,12 @@ import {bundleChanges, Computed, dom, DomContents, DomElementArg, fromKo, MultiH
Observable, styled} from 'grainjs';
import * as ko from 'knockout';
export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, cursor: ko.Computed<CursorPos>) {
export function buildNameConfig(
owner: MultiHolder,
origColumn: ColumnRec,
cursor: ko.Computed<CursorPos>,
disabled: ko.Computed<boolean> // Whether the name is editable (it's not editable for multiple selected columns).
) {
const untieColId = origColumn.untieColIdFromLabel;
const editedLabel = Observable.create(owner, '');
@ -37,6 +42,12 @@ export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, curso
})
);
const toggleUntieColId = () => {
if (!origColumn.disableModify.peek() && !disabled.peek()) {
untieColId.saveOnly(!untieColId.peek()).catch(reportError);
}
};
return [
cssLabel('COLUMN LABEL AND ID'),
cssRow(
@ -45,12 +56,13 @@ export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, curso
editor = cssInput(fromKo(origColumn.label),
async val => { await origColumn.label.saveOnly(val); editedLabel.set(''); },
dom.on('input', (ev, elem) => { if (!untieColId.peek()) { editedLabel.set(elem.value); } }),
dom.boolAttr('disabled', origColumn.disableModify),
dom.boolAttr('readonly', use => use(origColumn.disableModify) || use(disabled)),
testId('field-label'),
),
cssInput(editableColId,
saveColId,
dom.boolAttr(`readonly`, use => use(origColumn.disableModify) || !use(origColumn.untieColIdFromLabel)),
dom.boolAttr('readonly',
use => use(disabled) || use(origColumn.disableModify) || !use(origColumn.untieColIdFromLabel)),
cssCodeBlock.cls(''),
{style: 'margin-top: 8px'},
testId('field-col-id'),
@ -60,8 +72,8 @@ export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, curso
cssColTieConnectors(),
cssToggleButton(icon('FieldReference'),
cssToggleButton.cls('-selected', (use) => !use(untieColId)),
dom.on('click', () => !origColumn.disableModify.peek() && untieColId.saveOnly(!untieColId.peek())),
cssToggleButton.cls("-disabled", origColumn.disableModify),
dom.on('click', toggleUntieColId),
cssToggleButton.cls("-disabled", use => use(origColumn.disableModify) || use(disabled)),
testId('field-derive-id')
),
)
@ -78,12 +90,13 @@ type BuildEditor = (
onSave?: SaveHandler,
onCancel?: () => void) => void;
type BEHAVIOR = "empty"|"formula"|"data";
export function buildFormulaConfig(
owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor
) {
// If we can't modify anything about the column.
const disableModify = Computed.create(owner, use => use(origColumn.disableModify));
// Intermediate state - user wants to specify formula, but haven't done yet
const maybeFormula = Observable.create(owner, false);
@ -93,7 +106,7 @@ export function buildFormulaConfig(
// If this column belongs to a summary table.
const isSummaryTable = Computed.create(owner, use => Boolean(use(use(origColumn.table).summarySourceTable)));
// Column behaviour. There are 3 types of behaviors:
// Column behavior. There are 3 types of behaviors:
// - empty: isFormula and formula == ''
// - formula: isFormula and formula != ''
// - data: not isFormula nd formula == ''
@ -123,31 +136,89 @@ export function buildFormulaConfig(
owner.autoDispose(origColumn.formula.subscribe(clearState));
owner.autoDispose(origColumn.isFormula.subscribe(clearState));
// User might have selected multiple columns, in that case all elements will be disabled, except the menu.
// If user has selected only empty or formula columns, we offer to reset all or to convert to data.
// If user has selected any data column, we offer only to reset all.
const viewSection = Computed.create(owner, use => {
return use(gristDoc.currentView)?.viewSection;
});
const isMultiSelect = Computed.create(owner, use => {
const vs = use(viewSection);
return !!vs && use(vs.selectedFields).length > 1;
});
// If all columns are empty or have formulas.
const multiType = Computed.create(owner, use => {
if (!use(isMultiSelect)) { return false; }
const vs = use(viewSection);
if (!vs) { return false; }
return use(vs.columnsBehavior);
});
// If all columns are empty or have formulas.
const isFormulaLike = Computed.create(owner, use => {
if (!use(isMultiSelect)) { return false; }
const vs = use(viewSection);
if (!vs) { return false; }
return use(vs.columnsAllIsFormula);
});
// Helper to get all selected columns refs.
const selectedColumns = () => viewSection.get()?.selectedFields.peek().map(f => f.column.peek()) || [];
const selectedColumnIds = () => selectedColumns().map(f => f.id.peek()) || [];
// Clear and reset all option for multiple selected columns.
const clearAndResetAll = () => selectOption(
() => Promise.all([
gristDoc.clearColumns(selectedColumnIds())
]),
'Clear and reset', 'CrossSmall'
);
// Convert to data option for multiple selected columns.
const convertToDataAll = () => selectOption(
() => gristDoc.convertIsFormula(selectedColumnIds(), {toFormula: false, noRecalc: true}),
'Convert columns to data', 'Database',
dom.cls('disabled', isSummaryTable)
);
// Menu helper that will show normal menu with some default options
const menu = (label: DomContents, options: DomElementArg[]) =>
cssRow(
selectMenu(
label,
() => options,
() => !isMultiSelect.get() ? options : [
isFormulaLike.get() ? convertToDataAll() : null,
clearAndResetAll(),
],
testId("field-behaviour"),
// HACK: Menu helper will add tabindex to this element, which will make
// this element focusable and will steal focus from clipboard. This in turn,
// will not dispose the formula editor when menu is clicked.
(el) => el.removeAttribute("tabindex"),
dom.cls(cssBlockedCursor.className, origColumn.disableModify),
dom.cls("disabled", origColumn.disableModify)),
dom.cls(cssBlockedCursor.className, disableModify),
dom.cls("disabled", disableModify)),
);
// Behaviour label
// Behavior label
const behaviorName = Computed.create(owner, behavior, (use, type) => {
if (type === 'formula') { return "Formula Column"; }
if (type === 'data') { return "Data Column"; }
return "Empty Column";
if (use(isMultiSelect)) {
const commonType = use(multiType);
if (commonType === 'formula') { return "Formula Columns"; }
if (commonType === 'data') { return "Data Columns"; }
if (commonType === 'mixed') { return "Mixed Behavior"; }
return "Empty Columns";
} else {
if (type === 'formula') { return "Formula Column"; }
if (type === 'data') { return "Data Column"; }
return "Empty Column";
}
});
const behaviorIcon = Computed.create<IconName>(owner, (use) => {
return use(behaviorName) === "Data Column" ? "Database" : "Script";
return use(behaviorName).startsWith("Data Column") ? "Database" : "Script";
});
const behaviourLabel = () => selectTitle(behaviorName, behaviorIcon);
const behaviorLabel = () => selectTitle(behaviorName, behaviorIcon);
// Actions on select menu:
@ -226,6 +297,11 @@ export function buildFormulaConfig(
}
};
// Should we disable all other action buttons and formula editor. For now
// we will disable them when multiple columns are selected, or any of the column selected
// can't be modified.
const disableOtherActions = Computed.create(owner, use => use(disableModify) || use(isMultiSelect));
const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);
// Helper that will create different flavors for formula builder.
const formulaBuilder = (onSave: SaveHandler) => [
@ -233,6 +309,7 @@ export function buildFormulaConfig(
origColumn,
buildEditor,
"Enter formula",
disableOtherActions,
onSave,
clearState)),
dom.maybe(errorMessage, errMsg => cssRow(cssError(errMsg), testId('field-error-count'))),
@ -241,30 +318,30 @@ export function buildFormulaConfig(
return dom.maybe(behavior, (type: BEHAVIOR) => [
cssLabel('COLUMN BEHAVIOR'),
...(type === "empty" ? [
menu(behaviourLabel(), [
menu(behaviorLabel(), [
convertToDataOption(),
]),
cssEmptySeparator(),
cssRow(textButton(
"Set formula",
dom.on("click", setFormula),
dom.prop("disabled", origColumn.disableModify),
dom.prop("disabled", disableOtherActions),
testId("field-set-formula")
)),
cssRow(textButton(
"Set trigger formula",
dom.on("click", setTrigger),
dom.prop("disabled", use => use(isSummaryTable) || use(origColumn.disableModify)),
dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
testId("field-set-trigger")
)),
cssRow(textButton(
"Make into data column",
dom.on("click", convertToData),
dom.prop("disabled", use => use(isSummaryTable) || use(origColumn.disableModify)),
dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
testId("field-set-data")
))
] : type === "formula" ? [
menu(behaviourLabel(), [
menu(behaviorLabel(), [
convertToDataOption(),
clearAndResetOption(),
]),
@ -274,11 +351,11 @@ export function buildFormulaConfig(
"Convert to trigger formula",
dom.on("click", convertFormulaToTrigger),
dom.hide(maybeFormula),
dom.prop("disabled", use => use(isSummaryTable) || use(origColumn.disableModify)),
dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
testId("field-set-trigger")
))
] : /* type == 'data' */ [
menu(behaviourLabel(),
menu(behaviorLabel(),
[
dom.domComputed(origColumn.hasTriggerFormula, (hasTrigger) => hasTrigger ?
// If we have trigger, we will convert it directly to a formula column
@ -293,7 +370,10 @@ export function buildFormulaConfig(
dom.maybe((use) => use(maybeTrigger) || use(origColumn.hasTriggerFormula), () => [
cssLabel('TRIGGER FORMULA'),
formulaBuilder(onSaveConvertToTrigger),
dom.create(buildFormulaTriggers, origColumn, maybeTrigger)
dom.create(buildFormulaTriggers, origColumn, {
disabled: disableOtherActions,
notTrigger: maybeTrigger,
})
]),
// Else offer a way to convert to trigger formula.
dom.maybe((use) => !(use(maybeTrigger) || use(origColumn.hasTriggerFormula)), () => [
@ -301,7 +381,7 @@ export function buildFormulaConfig(
cssRow(textButton(
"Set trigger formula",
dom.on("click", convertDataColumnToTriggerColumn),
dom.prop("disabled", origColumn.disableModify),
dom.prop("disabled", disableOtherActions),
testId("field-set-trigger")
))
])
@ -313,11 +393,12 @@ function buildFormula(
column: ColumnRec,
buildEditor: BuildEditor,
placeholder: string,
disabled: Observable<boolean>,
onSave?: SaveHandler,
onCancel?: () => void) {
return cssFieldFormula(column.formula, {placeholder, maxLines: 2},
dom.cls('formula_field_sidepane'),
cssFieldFormula.cls('-disabled', column.disableModify),
cssFieldFormula.cls('-disabled', disabled),
cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)),
dom.cls('disabled'),
{tabIndex: '-1'},

@ -188,6 +188,21 @@ export class RightPanel extends Disposable {
return vsi && vsi.activeFieldBuilder();
}));
const selectedColumns = owner.autoDispose(ko.computed(() => {
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
return vsi && vsi.selectedColumns ? vsi.selectedColumns() : null;
}));
const isMultiSelect = owner.autoDispose(ko.pureComputed(() => {
const list = selectedColumns();
return Boolean(list && list.length > 1);
}));
owner.autoDispose(selectedColumns.subscribe(cols => {
this._gristDoc.viewModel.activeSection()?.selectedFields(cols || []);
}));
this._gristDoc.viewModel.activeSection()?.selectedFields(selectedColumns.peek() || []);
const docModel = this._gristDoc.docModel;
const origColRef = owner.autoDispose(ko.computed(() => fieldBuilder()?.origColumn.origColRef() || 0));
const origColumn = owner.autoDispose(docModel.columns.createFloatingRowModel(origColRef));
@ -206,24 +221,44 @@ export class RightPanel extends Disposable {
const {buildNameConfig, buildFormulaConfig} = ViewPane.FieldConfig;
return dom.maybe(isColumnValid, () =>
buildConfigContainer(
dom.create(buildNameConfig, origColumn, cursor),
cssSection(
dom.create(buildNameConfig, origColumn, cursor, isMultiSelect),
),
cssSeparator(),
dom.create(buildFormulaConfig, origColumn, this._gristDoc, this._activateFormulaEditor.bind(this)),
cssSection(
dom.create(buildFormulaConfig,
origColumn, this._gristDoc, this._activateFormulaEditor.bind(this)),
),
cssSeparator(),
cssLabel('COLUMN TYPE'),
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [
builder.buildSelectTypeDom(),
builder.buildSelectWidgetDom(),
builder.buildConfigDom()
cssLabel('COLUMN TYPE'),
cssSection(
builder.buildSelectTypeDom(),
),
cssSection(
builder.buildSelectWidgetDom(),
),
cssSection(
builder.buildConfigDom(),
),
builder.buildColorConfigDom(),
cssSection(
builder.buildSettingOptions(),
dom.maybe(isMultiSelect, () => disabledSection())
),
]),
cssSeparator(),
dom.maybe(refSelect.isForeignRefCol, () => [
cssLabel('Add referenced columns'),
cssRow(refSelect.buildDom()),
cssSeparator()
]),
cssLabel('TRANSFORM'),
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => builder.buildTransformDom()),
cssSection(
dom.maybe(refSelect.isForeignRefCol, () => [
cssLabel('Add referenced columns'),
cssRow(refSelect.buildDom()),
cssSeparator()
]),
cssLabel('TRANSFORM'),
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => builder.buildTransformDom()),
dom.maybe(isMultiSelect, () => disabledSection()),
testId('panel-transform'),
),
this._disableIfReadonly(),
)
);
@ -239,7 +274,7 @@ export class RightPanel extends Disposable {
// Custom save handler.
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
// Custom cancel handler.
onCancel?: () => void,) {
onCancel?: () => void) {
const vsi = this._gristDoc.viewModel.activeSection().viewInstance();
if (!vsi) { return; }
const editRowModel = vsi.moveEditRowToCursor();
@ -527,6 +562,12 @@ export class RightPanel extends Disposable {
}
}
function disabledSection() {
return cssOverlay(
testId('panel-disabled-section'),
);
}
export function buildConfigContainer(...args: DomElementArg[]): HTMLElement {
return cssConfigContainer(
// The `position: relative;` style is needed for the overlay for the readonly mode. Note that
@ -774,3 +815,7 @@ const cssTextInput = styled(textInput, `
pointer-events: none;
}
`);
const cssSection = styled('div', `
position: relative;
`);

@ -20,7 +20,10 @@ import isEqual = require('lodash/isEqual');
/**
* Build UI to select triggers for formulas in data columns (such for default values).
*/
export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, disable: Observable<boolean>|null = null) {
export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, options: {
notTrigger?: Observable<boolean>|null // if column is not yet a trigger,
disabled?: Observable<boolean>
}) {
// Set up observables to translate between the UI representation of triggers, and what we
// actually store.
// - We store the pair (recalcWhen, recalcDeps). When recalcWhen is DEFAULT, recalcDeps lists
@ -74,12 +77,26 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, disa
return deps.map(dep => use(docModel.columns.getRowModel(dep)?.label)).join(", ");
});
const changesDisabled = Computed.create(owner, use => {
return Boolean(
(options.disabled && use(options.disabled)) ||
(options.notTrigger && use(options.notTrigger))
);
});
const newRowsDisabled = Computed.create(owner, use => {
return Boolean(
use(applyOnChanges) || use(changesDisabled)
);
});
return [
cssRow(
labeledSquareCheckbox(
applyToNew,
'Apply to new records',
dom.boolAttr('disabled', (use) => (disable && use(disable)) || use(applyOnChanges)),
dom.boolAttr('disabled', newRowsDisabled),
testId('field-formula-apply-to-new'),
),
),
@ -90,7 +107,7 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, disa
'Apply on changes to:' :
'Apply on record changes'
),
dom.boolAttr('disabled', (use) => disable ? use(disable) : false),
dom.boolAttr('disabled', changesDisabled),
testId('field-formula-apply-on-changes'),
),
),
@ -100,6 +117,7 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, disa
cssSelectSummary(dom.text(summaryText)),
icon('Dropdown'),
testId('field-triggers-select'),
dom.cls('disabled', use => !!options.disabled && use(options.disabled)),
elem => {
setPopupToCreateDom(elem, ctl => buildTriggerSelectors(ctl, column.table.peek(), column, setRecalc),
{...defaultMenuOptions, placement: 'bottom-end'});

@ -6,7 +6,7 @@ import {IconName} from 'app/client/ui2018/IconList';
import {icon} from 'app/client/ui2018/icons';
import {cssSelectBtn} from 'app/client/ui2018/select';
import {isValidHex} from 'app/common/gutil';
import {Computed, Disposable, dom, Observable, onKeyDown, styled} from 'grainjs';
import {BindableValue, Computed, Disposable, dom, Observable, onKeyDown, styled} from 'grainjs';
import {defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
export interface StyleOptions {
@ -44,12 +44,25 @@ export class ColorOption {
*/
export function colorSelect(
styleOptions: StyleOptions,
onSave: () => Promise<void>,
placeholder = 'Default cell style'): Element {
options: {
// Handler to save the style.
onSave: () => Promise<void>,
// Invoked when user opens the color picker.
onOpen?: () => void,
// Invoked when user closes the color picker without saving.
onRevert?: () => void,
placeholder?: BindableValue<string>
}): Element {
const {
textColor,
fillColor,
} = styleOptions;
const {
onSave,
onOpen,
onRevert,
placeholder = 'Default cell style',
} = options;
const selectBtn = cssSelectBtn(
cssContent(
cssButtonIcon(
@ -63,13 +76,16 @@ export function colorSelect(
cssLightBorder.cls(''),
testId('btn-icon'),
),
placeholder,
dom.text(placeholder),
),
icon('Dropdown'),
testId('color-select'),
);
const domCreator = (ctl: IOpenController) => buildColorPicker(ctl, styleOptions, onSave);
const domCreator = (ctl: IOpenController) => {
onOpen?.();
return buildColorPicker(ctl, styleOptions, onSave, onRevert);
};
setPopupToCreateDom(selectBtn, domCreator, {...defaultMenuOptions, placement: 'bottom-end'});
return selectBtn;
@ -105,7 +121,9 @@ function buildColorPicker(ctl: IOpenController,
fontItalic,
fontStrikethrough
}: StyleOptions,
onSave: () => Promise<void>): Element {
onSave: () => Promise<void>,
onRevert?: () => void,
): Element {
const textColorModel = ColorModel.create(null, textColor.color);
const fillColorModel = ColorModel.create(null, fillColor.color);
const fontBoldModel = BooleanModel.create(null, fontBold);
@ -119,7 +137,10 @@ function buildColorPicker(ctl: IOpenController,
const notChanged = Computed.create(null, use => models.every(m => use(m.needsSaving) === false));
function revert() {
models.forEach(m => m.revert());
onRevert?.();
if (!onRevert) {
models.forEach(m => m.revert());
}
ctl.close();
}
@ -129,8 +150,10 @@ function buildColorPicker(ctl: IOpenController,
// TODO: disable the trigger btn while saving
await onSave();
} catch (e) {
/* Does no logging: onSave() callback is expected to handle their reporting */
models.forEach(m => m.revert());
onRevert?.();
if (!onRevert) {
models.forEach(m => m.revert());
}
}
}
models.forEach(m => m.dispose());

@ -25,6 +25,8 @@ export type ISelectorOption<T> = (T & string) | ISelectorOptionFull<T>;
* A "light" style is supported in CSS by passing cssButtonSelect.cls('-light') as an additional
* argument.
*
* A disabled state is supported by passing cssButtonSelect.cls('-disabled').
*
* Usage:
* const fruit = observable("apple");
* buttonSelect(fruit, ["apple", "banana", "mango"]);
@ -61,13 +63,13 @@ export function buttonToggleSelect<T>(
/**
* Pre-made text alignment selector.
*/
export function alignmentSelect(obs: Observable<string>) {
export function alignmentSelect(obs: Observable<string>, ...domArgs: DomElementArg[]) {
const alignments: Array<ISelectorOption<string>> = [
{value: 'left', icon: 'LeftAlign'},
{value: 'center', icon: 'CenterAlign'},
{value: 'right', icon: 'RightAlign'}
];
return buttonSelect(obs, alignments, {}, testId('alignment-select'));
return buttonSelect(obs, alignments, {}, testId('alignment-select'), ...domArgs);
}
/**
@ -216,6 +218,14 @@ const cssSelectorBtn = styled('div', `
border: none;
background-color: ${theme.hover};
}
.${cssButtonSelect.className}-disabled > &,
.${cssButtonSelect.className}-disabled > &:hover {
--icon-color: ${theme.rightPanelToggleButtonDisabledFg};
color: ${theme.rightPanelToggleButtonDisabledFg};
background-color: ${theme.rightPanelToggleButtonDisabledBg};
border-color: ${theme.buttonGroupBorder};
pointer-events: none;
}
`);
const cssSelectorLabel = styled('span', `

@ -112,15 +112,24 @@ export class AttachmentsWidget extends NewAbstractWidget {
}
public buildConfigDom(): Element {
const inputRange = input(fromKo(this._height), {onInput: true}, {
style: 'margin: 0 5px;',
type: 'range',
min: '16',
max: '96',
value: '36'
}, testId('thumbnail-size'));
const options = this.field.config.options;
const height = options.prop('height');
const inputRange = input(
fromKo(height),
{onInput: true}, {
style: 'margin: 0 5px;',
type: 'range',
min: '16',
max: '96',
value: '36'
},
testId('thumbnail-size'),
// When multiple columns are selected, we can only edit height when all
// columns support it.
dom.prop('disabled', use => use(options.disabled('height'))),
);
// Save the height on change event (when the user releases the drag button)
onElem(inputRange, 'change', (ev: any) => { this._height.setAndSave(ev.target.value); });
onElem(inputRange, 'change', (ev: any) => { height.setAndSave(ev.target.value).catch(reportError); });
return cssRow(
sizeLabel('Size'),
inputRange

@ -1,11 +1,12 @@
import {allCommands} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {Style} from 'app/client/models/Styles';
import {textButton} from 'app/client/ui2018/buttons';
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {ConditionalStyle} from 'app/client/widgets/ConditionalStyle';
import {Disposable, dom, DomContents, fromKo, MultiHolder, Observable, styled} from 'grainjs';
import {Computed, Disposable, dom, DomContents, fromKo, MultiHolder, Observable, styled} from 'grainjs';
export class CellStyle extends Disposable {
private _textColor: Observable<string|undefined>;
@ -21,16 +22,29 @@ export class CellStyle extends Disposable {
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._textColor = fromKo(this._field.config.textColor);
this._fillColor = fromKo(this._field.config.fillColor);
this._fontBold = fromKo(this._field.config.fontBold);
this._fontUnderline = fromKo(this._field.config.fontUnderline);
this._fontItalic = fromKo(this._field.config.fontItalic);
this._fontStrikethrough = fromKo(this._field.config.fontStrikethrough);
}
public buildDom(): DomContents {
const holder = new MultiHolder();
const hasMixedStyle = Computed.create(holder, use => {
if (!use(this._field.config.multiselect)) { return false; }
const commonStyle = [
use(this._field.config.options.mixed('textColor')),
use(this._field.config.options.mixed('fillColor')),
use(this._field.config.options.mixed('fontBold')),
use(this._field.config.options.mixed('fontUnderline')),
use(this._field.config.options.mixed('fontItalic')),
use(this._field.config.options.mixed('fontStrikethrough'))
];
return commonStyle.some(Boolean);
});
let state: Style[]|null = null;
return [
cssLine(
cssLabel('CELL STYLE', dom.autoDispose(holder)),
@ -49,12 +63,16 @@ export class CellStyle extends Disposable {
fontItalic: this._fontItalic,
fontUnderline: this._fontUnderline,
fontStrikethrough: this._fontStrikethrough
},
// Calling `field.widgetOptionsJson.save()` saves both fill and text color settings.
() => this._field.widgetOptionsJson.save()
}, {
// Calling `field.config.options.save()` saves all options at once.
onSave: () => this._field.config.options.save(),
onOpen: () => state = this._field.config.copyStyles(),
onRevert: () => this._field.config.setStyles(state),
placeholder: use => use(hasMixedStyle) ? 'Mixed style' : 'Default cell style'
}
)
),
dom.create(ConditionalStyle, "Cell Style", this._field, this._gristDoc)
dom.create(ConditionalStyle, "Cell Style", this._field, this._gristDoc, fromKo(this._field.config.multiselect))
];
}
}

@ -1,5 +1,6 @@
import {createGroup} from 'app/client/components/commands';
import {ACIndexImpl, ACItem, ACResults, buildHighlightedDom, normalizeText, HighlightFunc} from 'app/client/lib/ACIndex';
import {ACIndexImpl, ACItem, ACResults,
buildHighlightedDom, HighlightFunc, normalizeText} from 'app/client/lib/ACIndex';
import {IAutocompleteOptions} from 'app/client/lib/autocomplete';
import {IToken, TokenField, tokenFieldStyles} from 'app/client/lib/TokenField';
import {colors, testId, theme} from 'app/client/ui2018/cssVars';
@ -10,10 +11,10 @@ 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';
import {dom, styled} from 'grainjs';
import {ChoiceOptions, getRenderFillColor, getRenderTextColor} from 'app/client/widgets/ChoiceTextBox';
import {choiceToken, cssChoiceACItem, cssChoiceToken} from 'app/client/widgets/ChoiceToken';
import {icon} from 'app/client/ui2018/icons';
import {dom, styled} from 'grainjs';
export class ChoiceItem implements ACItem, IToken {
public cleanText: string = normalizeText(this.label);

@ -6,7 +6,7 @@ import {colors, testId, theme} from 'app/client/ui2018/cssVars';
import {editableLabel} from 'app/client/ui2018/editableLabel';
import {icon} from 'app/client/ui2018/icons';
import {ChoiceOptionsByName, IChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
import {Computed, Disposable, dom, DomContents, DomElementArg, Holder, Observable, styled} from 'grainjs';
import {Computed, Disposable, dom, DomContents, DomElementArg, Holder, MultiHolder, Observable, styled} from 'grainjs';
import {createCheckers, iface, ITypeSuite, opt, union} from 'ts-interface-checker';
import isEqual = require('lodash/isEqual');
import uniqBy = require('lodash/uniqBy');
@ -95,7 +95,8 @@ export class ChoiceListEntry extends Disposable {
private _values: Observable<string[]>,
private _choiceOptionsByName: Observable<ChoiceOptionsByName>,
private _onSave: (values: string[], choiceOptions: ChoiceOptionsByName, renames: Record<string, string>) => void,
private _disabled: Observable<boolean>
private _disabled: Observable<boolean>,
private _mixed: Observable<boolean>,
) {
super();
@ -110,10 +111,12 @@ export class ChoiceListEntry extends Disposable {
public buildDom(maxRows: number = 6): DomContents {
return dom.domComputed(this._isEditing, (editMode) => {
if (editMode) {
// If we have mixed values, we can't show any options on the editor.
const initialValue = this._mixed.get() ? [] : this._values.get().map(label => {
return new ChoiceItem(label, label, this._choiceOptionsByName.get().get(label));
});
const tokenField = TokenField.ctor<ChoiceItem>().create(this._tokenFieldHolder, {
initialValue: this._values.get().map(label => {
return new ChoiceItem(label, label, this._choiceOptionsByName.get().get(label));
}),
initialValue,
renderToken: token => this._renderToken(token),
createToken: label => new ChoiceItem(label, null),
clipboardToTokens: clipboardToChoices,
@ -155,57 +158,67 @@ export class ChoiceListEntry extends Disposable {
dom.onKeyDown({Enter$: () => this._save()}),
);
} else {
const someValues = Computed.create(null, this._values, (_use, values) =>
const holder = new MultiHolder();
const someValues = Computed.create(holder, this._values, (_use, values) =>
values.length <= maxRows ? values : values.slice(0, maxRows - 1));
const noChoices = Computed.create(holder, someValues, (_use, values) => values.length === 0);
return cssVerticalFlex(
cssListBoxInactive(
dom.cls(cssBlockedCursor.className, this._disabled),
dom.autoDispose(someValues),
dom.maybe(use => use(someValues).length === 0, () =>
row('No choices configured')
),
dom.domComputed(this._choiceOptionsByName, (choiceOptions) =>
dom.forEach(someValues, val => {
return row(
cssTokenColorInactive(
dom.style('background-color', getFillColor(choiceOptions.get(val)) || '#FFFFFF'),
dom.style('color', getTextColor(choiceOptions.get(val)) || '#000000'),
dom.cls('font-bold', choiceOptions.get(val)?.fontBold ?? false),
dom.cls('font-underline', choiceOptions.get(val)?.fontUnderline ?? false),
dom.cls('font-italic', choiceOptions.get(val)?.fontItalic ?? false),
dom.cls('font-strikethrough', choiceOptions.get(val)?.fontStrikethrough ?? false),
'T',
testId('choice-list-entry-color')
),
cssTokenLabel(
val,
testId('choice-list-entry-label')
dom.autoDispose(holder),
dom.maybe(this._mixed, () => [
cssListBoxInactive(
dom.cls(cssBlockedCursor.className, this._disabled),
row('Mixed configuration')
)
]),
dom.maybe(use => !use(this._mixed), () => [
cssListBoxInactive(
dom.cls(cssBlockedCursor.className, this._disabled),
dom.maybe(noChoices, () => row('No choices configured')),
dom.domComputed(this._choiceOptionsByName, (choiceOptions) =>
dom.forEach(someValues, val => {
return row(
cssTokenColorInactive(
dom.style('background-color', getFillColor(choiceOptions.get(val)) || '#FFFFFF'),
dom.style('color', getTextColor(choiceOptions.get(val)) || '#000000'),
dom.cls('font-bold', choiceOptions.get(val)?.fontBold ?? false),
dom.cls('font-underline', choiceOptions.get(val)?.fontUnderline ?? false),
dom.cls('font-italic', choiceOptions.get(val)?.fontItalic ?? false),
dom.cls('font-strikethrough', choiceOptions.get(val)?.fontStrikethrough ?? false),
'T',
testId('choice-list-entry-color')
),
cssTokenLabel(
val,
testId('choice-list-entry-label')
)
);
}),
),
// Show description row for any remaining rows
dom.maybe(use => use(this._values).length > maxRows, () =>
row(
dom('span',
testId('choice-list-entry-label'),
dom.text((use) => `+${use(this._values).length - (maxRows - 1)} more`)
)
);
}),
),
// Show description row for any remaining rows
dom.maybe(use => use(this._values).length > maxRows, () =>
row(
dom('span',
testId('choice-list-entry-label'),
dom.text((use) => `+${use(this._values).length - (maxRows - 1)} more`)
)
)
),
dom.on('click', () => this._startEditing()),
cssListBoxInactive.cls("-disabled", this._disabled),
testId('choice-list-entry')
),
dom.on('click', () => this._startEditing()),
cssListBoxInactive.cls("-disabled", this._disabled),
testId('choice-list-entry')
),
dom.maybe(use => !use(this._disabled), () =>
]),
dom.maybe(use => !use(this._disabled), () => [
cssButtonRow(
primaryButton('Edit',
primaryButton(
dom.text(use => use(this._mixed) ? 'Reset' : 'Edit'),
dom.on('click', () => this._startEditing()),
testId('choice-list-entry-edit')
)
)
)
),
),
]),
);
}
});

@ -7,7 +7,7 @@ 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';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {Computed, dom, fromKo, styled} from 'grainjs';
import {Computed, dom, styled} from 'grainjs';
export type IChoiceOptions = Style
export type ChoiceOptions = Record<string, IChoiceOptions | undefined>;
@ -66,16 +66,29 @@ export class ChoiceTextBox extends NTextBox {
}
public buildConfigDom() {
const disabled = Computed.create(null,
use => use(this.field.disableModify)
|| use(use(this.field.column).disableEditData)
|| use(this.field.config.options.disabled('choices'))
);
const mixed = Computed.create(null,
use => !use(disabled)
&& (use(this.field.config.options.mixed('choices')) || use(this.field.config.options.mixed('choiceOptions')))
);
return [
super.buildConfigDom(),
cssLabel('CHOICES'),
cssRow(
dom.autoDispose(disabled),
dom.autoDispose(mixed),
dom.create(
ChoiceListEntry,
this._choiceValues,
this._choiceOptionsByName,
this.save.bind(this),
fromKo(this.field.column().disableEditData)
disabled,
mixed
)
)
];
@ -95,11 +108,10 @@ export class ChoiceTextBox extends NTextBox {
protected save(choices: string[], choiceOptions: ChoiceOptionsByName, renames: Record<string, string>) {
const options = {
...this.options.peek(),
choices,
choiceOptions: toObject(choiceOptions)
};
return this.field.updateChoices(renames, options);
return this.field.config.updateChoices(renames, options);
}
}

@ -27,7 +27,7 @@ export class ConditionalStyle extends Disposable {
private _label: string,
private _ruleOwner: RuleOwner,
private _gristDoc: GristDoc,
private _disabled?: Observable<boolean>
) {
super();
this._currentRecord = Computed.create(this, use => {
@ -70,7 +70,8 @@ export class ConditionalStyle extends Disposable {
textButton(
'Add conditional style',
testId('add-conditional-style'),
dom.on('click', () => this._ruleOwner.addEmptyRule())
dom.on('click', () => this._ruleOwner.addEmptyRule()),
dom.prop('disabled', this._disabled)
),
dom.hide(use => use(this._ruleOwner.hasRules))
),
@ -78,7 +79,7 @@ export class ConditionalStyle extends Disposable {
use => use(this._ruleOwner.rulesCols),
(owner, rules) =>
cssRuleList(
dom.show(rules.length > 0),
dom.show(use => rules.length > 0 && (!this._disabled || !use(this._disabled))),
...rules.map((column, ruleIndex) => {
const textColor = this._buildStyleOption(owner, ruleIndex, 'textColor');
const fillColor = this._buildStyleOption(owner, ruleIndex, 'fillColor');
@ -127,9 +128,10 @@ export class ConditionalStyle extends Disposable {
fontItalic,
fontUnderline,
fontStrikethrough
},
save,
this._label || 'Conditional Style'
}, {
onSave: save,
placeholder: this._label || 'Conditional Style',
}
)
),
cssRemoveButton(
@ -146,6 +148,7 @@ export class ConditionalStyle extends Disposable {
textButton('Add another rule',
dom.on('click', () => this._ruleOwner.addEmptyRule()),
testId('add-another-rule'),
dom.prop('disabled', use => this._disabled && use(this._disabled))
),
dom.show(use => use(this._ruleOwner.hasRules))
),

@ -7,13 +7,14 @@ import {currencies} from 'app/common/Locales';
interface CurrencyPickerOptions {
// The label to use in the select menu for the default option.
defaultCurrencyLabel: string;
disabled?: Observable<boolean>;
}
export function buildCurrencyPicker(
owner: IDisposableOwner,
currency: Observable<string|undefined>,
onSave: (value: string|undefined) => void,
{defaultCurrencyLabel}: CurrencyPickerOptions
{defaultCurrencyLabel, disabled}: CurrencyPickerOptions
) {
const currencyItems: ACSelectItem[] = currencies
.map(item => ({
@ -35,6 +36,7 @@ export function buildCurrencyPicker(
return buildACSelect(owner,
{
acIndex, valueObs,
disabled,
save(_, item: ACSelectItem | undefined) {
// Save only if we have found a match
if (!item) {

@ -7,7 +7,7 @@ var kf = require('../lib/koForm');
var AbstractWidget = require('./AbstractWidget');
const {fromKoSave} = require('app/client/lib/fromKoSave');
const {alignmentSelect} = require('app/client/ui2018/buttonSelect');
const {alignmentSelect, cssButtonSelect} = require('app/client/ui2018/buttonSelect');
const {cssRow, cssLabel} = require('app/client/ui/RightPanelStyles');
const {cssTextInput} = require("app/client/ui2018/editableLabel");
const {styled, fromKo} = require('grainjs');
@ -21,18 +21,21 @@ function DateTextBox(field) {
AbstractWidget.call(this, field);
this.alignment = this.options.prop('alignment');
this.dateFormat = this.options.prop('dateFormat');
this.isCustomDateFormat = this.options.prop('isCustomDateFormat');
// These properties are only used in configuration.
this.dateFormat = this.field.config.options.prop('dateFormat');
this.isCustomDateFormat = this.field.config.options.prop('isCustomDateFormat');
this.mixedDateFormat = ko.pureComputed(() => this.dateFormat() === null || this.isCustomDateFormat() === null);
// Helper to set 'dateFormat' and 'isCustomDateFormat' from the set of default date format strings.
this.standardDateFormat = this.autoDispose(ko.computed({
owner: this,
read: function() { return this.isCustomDateFormat() ? 'Custom' : this.dateFormat(); },
read: function() { return this.mixedDateFormat() ? null : this.isCustomDateFormat() ? 'Custom' : this.dateFormat(); },
write: function(val) {
if (val === 'Custom') { this.isCustomDateFormat.setAndSave(true); }
else {
this.options.update({isCustomDateFormat: false, dateFormat: val});
this.options.save();
this.field.config.options.update({isCustomDateFormat: false, dateFormat: val});
this.field.config.options.save();
}
}
}));
@ -44,12 +47,18 @@ dispose.makeDisposable(DateTextBox);
_.extend(DateTextBox.prototype, AbstractWidget.prototype);
DateTextBox.prototype.buildDateConfigDom = function() {
var self = this;
const disabled = this.field.config.options.disabled('dateFormat');
return dom('div',
cssLabel("Date Format"),
cssRow(dom(select(fromKo(self.standardDateFormat), [...dateFormatOptions, "Custom"]), dom.testId("Widget_dateFormat"))),
kd.maybe(self.isCustomDateFormat, function() {
return cssRow(dom(textbox(self.dateFormat), dom.testId("Widget_dateCustomFormat")));
cssRow(dom(select(
fromKo(this.standardDateFormat),
[...dateFormatOptions, "Custom"],
{ disabled, defaultLabel: "Mixed format" },
), dom.testId("Widget_dateFormat"))),
kd.maybe(() => !this.mixedDateFormat() && this.isCustomDateFormat(), () => {
return cssRow(dom(
textbox(this.dateFormat, { disabled }),
dom.testId("Widget_dateCustomFormat")));
})
);
};
@ -58,7 +67,10 @@ DateTextBox.prototype.buildConfigDom = function() {
return dom('div',
this.buildDateConfigDom(),
cssRow(
alignmentSelect(fromKoSave(this.alignment))
alignmentSelect(
fromKoSave(this.field.config.options.prop('alignment')),
cssButtonSelect.cls('-disabled', this.field.config.options.disabled('alignment')),
),
)
);
};
@ -91,8 +103,8 @@ const cssFocus = styled('div', `
`)
// helper method to create old style textbox that looks like a new one
function textbox(value) {
const textDom = kf.text(value);
function textbox(value, options) {
const textDom = kf.text(value, options ?? {});
const tzInput = textDom.querySelector('input');
dom(tzInput,
kd.cssClass(cssTextInput.className),

@ -9,7 +9,7 @@ var DateTextBox = require('./DateTextBox');
var gutil = require('app/common/gutil');
const {fromKoSave} = require('app/client/lib/fromKoSave');
const {alignmentSelect} = require('app/client/ui2018/buttonSelect');
const {alignmentSelect, cssButtonSelect} = require('app/client/ui2018/buttonSelect');
const {cssRow, cssLabel} = require('app/client/ui/RightPanelStyles');
const {cssTextInput} = require("app/client/ui2018/editableLabel");
const {dom: gdom, styled, fromKo} = require('grainjs');
@ -30,8 +30,9 @@ function DateTimeTextBox(field) {
this._setTimezone = (val) => field.column().type.setAndSave('DateTime:' + val);
this.timeFormat = this.options.prop('timeFormat');
this.isCustomTimeFormat = this.options.prop('isCustomTimeFormat');
this.timeFormat = this.field.config.options.prop('timeFormat');
this.isCustomTimeFormat = this.field.config.options.prop('isCustomTimeFormat');
this.mixedTimeFormat = ko.pureComputed(() => this.timeFormat() === null || this.isCustomTimeFormat() === null);
// Helper to set 'timeFormat' and 'isCustomTimeFormat' from the set of default time format strings.
this.standardTimeFormat = this.autoDispose(ko.computed({
@ -54,21 +55,39 @@ _.extend(DateTimeTextBox.prototype, DateTextBox.prototype);
* builds only the necessary dom for the transform config menu.
*/
DateTimeTextBox.prototype.buildConfigDom = function(isTransformConfig) {
var self = this;
const disabled = ko.pureComputed(() => {
return this.field.config.options.disabled('timeFormat')() || this.field.column().disableEditData();
});
const alignment = fromKoSave(this.field.config.options.prop('alignment'));
return dom('div',
cssLabel("Timezone"),
cssRow(
gdom.create(buildTZAutocomplete, moment, fromKo(this._timezone), this._setTimezone,
{ disabled : fromKo(this.field.column().disableEditData)}),
{ disabled : fromKo(disabled)}),
),
self.buildDateConfigDom(),
this.buildDateConfigDom(),
cssLabel("Time Format"),
cssRow(dom(select(fromKo(self.standardTimeFormat), [...timeFormatOptions, "Custom"]), dom.testId("Widget_timeFormat"))),
kd.maybe(self.isCustomTimeFormat, function() {
return cssRow(dom(textbox(self.timeFormat), dom.testId("Widget_timeCustomFormat")));
cssRow(dom(
select(
fromKo(this.standardTimeFormat),
[...timeFormatOptions, "Custom"],
{ disabled : fromKo(disabled), defaultLabel: 'Mixed format' }
),
dom.testId("Widget_timeFormat")
)),
kd.maybe(() => !this.mixedTimeFormat() && this.isCustomTimeFormat(), () => {
return cssRow(
dom(
textbox(this.timeFormat, { disabled: this.field.config.options.disabled('timeFormat')}),
dom.testId("Widget_timeCustomFormat")
)
);
}),
isTransformConfig ? null : cssRow(
alignmentSelect(fromKoSave(this.alignment))
alignmentSelect(
alignment,
cssButtonSelect.cls('-disabled', this.field.config.options.disabled('alignment')),
)
)
);
};
@ -94,8 +113,8 @@ const cssFocus = styled('div', `
// helper method to create old style textbox that looks like a new one
function textbox(value) {
const textDom = kf.text(value);
function textbox(value, options) {
const textDom = kf.text(value, options || {});
const tzInput = textDom.querySelector('input');
dom(tzInput,
kd.cssClass(cssTextInput.className),

@ -16,7 +16,7 @@ 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/RightPanelStyles';
import { buttonSelect } from 'app/client/ui2018/buttonSelect';
import { buttonSelect, cssButtonSelect } from 'app/client/ui2018/buttonSelect';
import { theme } from 'app/client/ui2018/cssVars';
import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
import { DiffBox } from 'app/client/widgets/DiffBox';
@ -31,7 +31,7 @@ import * as gristTypes from 'app/common/gristTypes';
import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes';
import { CellValue } from 'app/plugin/GristData';
import { Computed, Disposable, fromKo, dom as grainjsDom,
Holder, IDisposable, makeTestId, styled, toKo } from 'grainjs';
Holder, IDisposable, makeTestId, MultiHolder, styled, toKo } from 'grainjs';
import * as ko from 'knockout';
import * as _ from 'underscore';
@ -156,22 +156,7 @@ export class FieldBuilder extends Disposable {
}
}));
this.widget = ko.pureComputed<object>({
owner: this,
read() { return this.options().widget; },
write(widget) {
// Reset the entire JSON, so that all options revert to their defaults.
const previous = this.options.peek();
this.options.setAndSave({
widget,
// Persists color settings across widgets (note: we cannot use `field.fillColor` to get the
// current value because it returns a default value for `undefined`. Same for `field.textColor`.
fillColor: previous.fillColor,
textColor: previous.textColor,
}).catch(reportError);
}
});
this.widget = ko.pureComputed(() => this.field.widget());
// Whether there is a pending call that transforms column.
this.isCallPending = ko.observable(false);
@ -221,11 +206,36 @@ export class FieldBuilder extends Disposable {
value: label,
icon: typeWidgets[label].icon
}));
return widgetOptions.length <= 1 ? null : [
if (widgetOptions.length <= 1) { return null; }
// Here we need to accommodate the fact that the widget can be null, which
// won't be visible on a select component when disabled.
const defaultWidget = Computed.create(null, use => {
if (widgetOptions.length <= 2) {
return;
}
const value = use(this.field.config.widget);
return value;
});
defaultWidget.onWrite((value) => this.field.config.widget(value));
const disabled = Computed.create(null, use => !use(this.field.config.sameWidgets));
return [
cssLabel('CELL FORMAT'),
cssRow(
widgetOptions.length <= 2 ? buttonSelect(fromKo(this.widget), widgetOptions) :
select(fromKo(this.widget), widgetOptions),
grainjsDom.autoDispose(defaultWidget),
widgetOptions.length <= 2 ?
buttonSelect(
fromKo(this.field.config.widget),
widgetOptions,
cssButtonSelect.cls("-disabled", disabled),
) :
select(
defaultWidget,
widgetOptions,
{
disabled,
defaultLabel: 'Mixed format'
}
),
testId('widget-select')
)
];
@ -236,21 +246,42 @@ export class FieldBuilder extends Disposable {
* Build the type change dom.
*/
public buildSelectTypeDom() {
const selectType = Computed.create(null, (use) => use(fromKo(this._readOnlyPureType)));
selectType.onWrite(newType => newType === this._readOnlyPureType.peek() || this._setType(newType));
const holder = new MultiHolder();
const commonType = Computed.create(holder, use => use(use(this.field.viewSection).columnsType));
const selectType = Computed.create(holder, (use) => {
const myType = use(fromKo(this._readOnlyPureType));
return use(commonType) === 'mixed' ? '' : myType;
});
selectType.onWrite(newType => {
const sameType = newType === this._readOnlyPureType.peek();
if (!sameType || commonType.get() === 'mixed') {
return this._setType(newType);
}
});
const onDispose = () => (this.isDisposed() || selectType.set(this.field.column().pureType()));
const allFormulas = Computed.create(holder, use => use(use(this.field.viewSection).columnsAllIsFormula));
return [
cssRow(
grainjsDom.autoDispose(selectType),
grainjsDom.autoDispose(holder),
select(selectType, this._availableTypes, {
disabled: (use) => use(this._isTransformingFormula) || use(this.origColumn.disableModifyBase) ||
disabled: (use) =>
// If we are transforming column at this moment (applying a formula to change data),
use(this._isTransformingFormula) ||
// If this is a summary column
use(this.origColumn.disableModifyBase) ||
// If there are multiple column selected, but all have different type than Any.
(use(this.field.config.multiselect) && !use(allFormulas)) ||
// If we are waiting for a server response
use(this.isCallPending),
menuCssClass: cssTypeSelectMenu.className,
defaultLabel: 'Mixed types'
}),
testId('type-select'),
grainjsDom.cls('tour-type-selector'),
grainjsDom.cls(cssBlockedCursor.className, this.origColumn.disableModifyBase)
grainjsDom.cls(cssBlockedCursor.className, use =>
use(this.origColumn.disableModifyBase) ||
(use(this.field.config.multiselect) && !use(allFormulas))
),
),
grainjsDom.maybe((use) => use(this._isRef) && !use(this._isTransformingType), () => this._buildRefTableSelect()),
grainjsDom.maybe(this._isTransformingType, () => {
@ -273,9 +304,19 @@ export class FieldBuilder extends Disposable {
public _setType(newType: string): Promise<unknown>|undefined {
if (this.origColumn.isFormula()) {
// Do not type transform a new/empty column or a formula column. Just make a best guess for
// the full type, and set it.
// the full type, and set it. If multiple columns are selected (and all are formulas/empty),
// then we will set the type for all of them using full type guessed from the first column.
const column = this.field.column();
column.type.setAndSave(addColTypeSuffix(newType, column, this._docModel)).catch(reportError);
const calculatedType = addColTypeSuffix(newType, column, this._docModel);
// If we selected multiple empty/formula columns, make the change for all of them.
if (this.field.viewSection.peek().selectedFields.peek().length > 1 &&
['formula', 'empty'].indexOf(this.field.viewSection.peek().columnsBehavior.peek())) {
return this.gristDoc.docData.bundleActions("Changing multiple column types", () =>
Promise.all(this.field.viewSection.peek().selectedFields.peek().map(f =>
f.column.peek().type.setAndSave(calculatedType)
))).catch(reportError);
}
column.type.setAndSave(calculatedType).catch(reportError);
} else if (!this.columnTransform) {
this.columnTransform = TypeTransform.create(null, this.gristDoc, this);
return this.columnTransform.prepare(newType);
@ -295,14 +336,18 @@ export class FieldBuilder extends Disposable {
icon: 'FieldTable' as const
}))
);
const isDisabled = Computed.create(null, use => {
return use(this.origColumn.disableModifyBase) || use(this.field.config.multiselect);
});
return [
cssLabel('DATA FROM TABLE'),
cssRow(
dom.autoDispose(allTables),
dom.autoDispose(isDisabled),
select(fromKo(this._refTableId), allTables, {
// Disallow changing the destination table when the column should not be modified
// (specifically when it's a group-by column of a summary table).
disabled: this.origColumn.disableModifyBase,
disabled: isDisabled,
}),
testId('ref-table-select')
)
@ -353,43 +398,62 @@ export class FieldBuilder extends Disposable {
* Builds the FieldBuilder Options Config DOM. Calls the buildConfigDom function of its widgetImpl.
*/
public buildConfigDom() {
// NOTE: adding a grainjsDom .maybe here causes the disposable order of the widgetImpl and
// the dom created by the widgetImpl to get out of sync.
return dom('div',
kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
dom('div', widget.buildConfigDom(), cssSeparator())
)
);
}
public buildColorConfigDom() {
// NOTE: adding a grainjsDom .maybe here causes the disposable order of the widgetImpl and
// the dom created by the widgetImpl to get out of sync.
return dom('div',
kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
dom('div', widget.buildColorConfigDom(this.gristDoc))
)
);
}
/**
* Builds the FieldBuilder Options Config DOM. Calls the buildConfigDom function of its widgetImpl.
*/
public buildSettingOptions() {
// NOTE: adding a grainjsDom .maybe here causes the disposable order of the widgetImpl and
// the dom created by the widgetImpl to get out of sync.
return dom('div',
kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
dom('div',
widget.buildConfigDom(),
cssSeparator(),
widget.buildColorConfigDom(this.gristDoc),
// If there is more than one field for this column (i.e. present in multiple views).
kd.maybe(() => this.origColumn.viewFields().all().length > 1, () =>
dom('div.fieldbuilder_settings',
kf.row(
kd.toggleClass('fieldbuilder_settings_header', true),
kf.label(
dom('div.fieldbuilder_settings_button',
dom.testId('FieldBuilder_settings'),
kd.text(() => this.field.useColOptions() ? 'Common' : 'Separate'), ' ▾',
menu(() => FieldSettingsMenu(
this.field.useColOptions(),
this.field.viewSection().isRaw(),
{
useSeparate: () => this.fieldSettingsUseSeparate(),
saveAsCommon: () => this.fieldSettingsSaveAsCommon(),
revertToCommon: () => this.fieldSettingsRevertToCommon(),
},
)),
),
'Field in ',
kd.text(() => this.origColumn.viewFields().all().length),
' views'
)
)
)
)
// If there is more than one field for this column (i.e. present in multiple views).
kd.maybe(() => this.origColumn.viewFields().all().length > 1, () =>
dom('div.fieldbuilder_settings',
kf.row(
kd.toggleClass('fieldbuilder_settings_header', true),
kf.label(
dom('div.fieldbuilder_settings_button',
dom.testId('FieldBuilder_settings'),
kd.text(() => this.field.useColOptions() ? 'Common' : 'Separate'), ' ▾',
menu(() => FieldSettingsMenu(
this.field.useColOptions(),
this.field.viewSection().isRaw(),
{
useSeparate: () => this.fieldSettingsUseSeparate(),
saveAsCommon: () => this.fieldSettingsSaveAsCommon(),
revertToCommon: () => this.fieldSettingsRevertToCommon(),
},
)),
),
'Field in ',
kd.text(() => this.origColumn.viewFields().all().length),
' views'
)
)
)
)
)
)
);
}

@ -3,12 +3,12 @@ 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/RightPanelStyles';
import { alignmentSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect';
import { alignmentSelect, cssButtonSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect';
import { colors, testId } from 'app/client/ui2018/cssVars';
import { cssIconBackground, icon } from 'app/client/ui2018/icons';
import { gristLink } from 'app/client/ui2018/links';
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
import { dom, DomArg, DomContents, fromKo, Observable, styled } from 'grainjs';
import { Computed, dom, DomArg, DomContents, fromKo, Observable, styled } from 'grainjs';
/**
* TextBox - The most basic widget for displaying text information.
@ -20,8 +20,8 @@ export class NTextBox extends NewAbstractWidget {
constructor(field: ViewFieldRec, options: Options = {}) {
super(field, options);
this.alignment = fromKoSave<string>(this.options.prop('alignment'));
this.wrapping = fromKo(this.field.wrapping);
this.alignment = fromKo(this.options.prop('alignment'));
this.wrapping = fromKo(this.field.wrap);
this.autoDispose(this.wrapping.addListener(() => {
this.field.viewSection().events.trigger('rowHeightChange');
@ -29,11 +29,28 @@ export class NTextBox extends NewAbstractWidget {
}
public buildConfigDom(): DomContents {
const toggle = () => {
const newValue = !this.field.config.wrap.peek();
this.field.config.wrap.setAndSave(newValue).catch(reportError);
};
const options = this.field.config.options;
// Some options might be disabled, as more than one column is selected.
// Prop observable is owned by the options object.
const alignmentDisabled = Computed.create(this, use => use(options.disabled('alignment')));
const wrapDisabled = Computed.create(this, (use) => use(options.disabled('wrap')));
return [
cssRow(
alignmentSelect(this.alignment),
alignmentSelect(
fromKoSave(this.field.config.alignment),
cssButtonSelect.cls('-disabled', alignmentDisabled),
),
dom('div', {style: 'margin-left: 8px;'},
makeButtonSelect(this.wrapping, [{value: true, icon: 'Wrap'}], this._toggleWrap.bind(this), {}),
makeButtonSelect(
fromKo(this.field.config.wrap),
[{value: true, icon: 'Wrap'}],
toggle,
cssButtonSelect.cls('-disabled', wrapDisabled),
),
testId('tb-wrap-text')
)
)
@ -48,12 +65,6 @@ export class NTextBox extends NewAbstractWidget {
dom.domComputed((use) => use(row._isAddRow) ? null : makeLinks(use(this.valueFormatter).formatAny(use(value))))
);
}
private _toggleWrap() {
const newValue = !this.wrapping.get();
this.options.update({wrap: newValue});
(this.options as any).save();
}
}
function makeLinks(text: string) {

@ -4,15 +4,16 @@
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors';
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
import {cssButtonSelect, ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
import {testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {clamp} from 'app/common/gutil';
import {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat';
import {Computed, dom, DomContents, DomElementArg, fromKo, MultiHolder, Observable, styled} from 'grainjs';
import {buildCurrencyPicker} from "app/client/widgets/CurrencyPicker";
import * as LocaleCurrency from "locale-currency";
import {BindableValue, Computed, dom, DomContents, DomElementArg,
fromKo, MultiHolder, Observable, styled} from 'grainjs';
import * as LocaleCurrency from 'locale-currency';
const modeOptions: Array<ISelectorOption<NumMode>> = [
@ -40,17 +41,19 @@ export class NumericTextBox extends NTextBox {
// Resolved options, to show default min/max decimals, which change depending on numMode.
const resolved = Computed.create<Intl.ResolvedNumberFormatOptions>(holder, (use) => {
const {numMode} = use(this.options);
const {numMode} = use(this.field.config.options);
const docSettings = use(this.field.documentSettings);
return buildNumberFormat({numMode}, docSettings).resolvedOptions();
});
// Prepare various observables that reflect the options in the UI.
const options = fromKo(this.options);
const fieldOptions = this.field.config.options;
const options = fromKo(fieldOptions);
const docSettings = fromKo(this.field.documentSettings);
const numMode = Computed.create(holder, options, (use, opts) => (opts.numMode as NumMode) || null);
const numSign = Computed.create(holder, options, (use, opts) => opts.numSign || null);
const currency = Computed.create(holder, options, (use, opts) => opts.currency);
const disabled = Computed.create(holder, use => use(this.field.config.options.disabled('currency')));
const minDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.decimals, ''));
const maxDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.maxDecimals, ''));
const defaultMin = Computed.create(holder, resolved, (use, res) => res.minimumFractionDigits);
@ -59,13 +62,13 @@ export class NumericTextBox extends NTextBox {
settings.currency ?? LocaleCurrency.getCurrency(settings.locale ?? 'en-US')
);
// Save a value as the given property in this.options() observable. Set it, save, and revert
// Save a value as the given property in fieldOptions observable. Set it, save, and revert
// on save error. This is similar to what modelUtil.setSaveValue() does.
const setSave = (prop: keyof NumberFormatOptions, value: unknown) => {
const orig = {...this.options.peek()};
const orig = {...fieldOptions.peek()};
if (value !== orig[prop]) {
this.options({...orig, [prop]: value, ...updateOptions(prop, value)});
this.options.save().catch((err) => { reportError(err); this.options(orig); });
fieldOptions({...orig, [prop]: value, ...updateOptions(prop, value)});
fieldOptions.save().catch((err) => { reportError(err); fieldOptions(orig); });
}
};
@ -78,28 +81,30 @@ export class NumericTextBox extends NTextBox {
const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined);
const setCurrency = (val: string|undefined) => setSave('currency', val);
const disabledStyle = cssButtonSelect.cls('-disabled', disabled);
return [
super.buildConfigDom(),
cssLabel('Number Format'),
cssRow(
dom.autoDispose(holder),
makeButtonSelect(numMode, modeOptions, setMode, {}, cssModeSelect.cls(''), testId('numeric-mode')),
makeButtonSelect(numSign, signOptions, setSign, {}, cssSignSelect.cls(''), testId('numeric-sign')),
makeButtonSelect(numMode, modeOptions, setMode, disabledStyle, cssModeSelect.cls(''), testId('numeric-mode')),
makeButtonSelect(numSign, signOptions, setSign, disabledStyle, cssSignSelect.cls(''), testId('numeric-sign')),
),
dom.maybe((use) => use(numMode) === 'currency', () => [
cssLabel('Currency'),
cssRow(
dom.domComputed(docCurrency, (defaultCurrency) =>
buildCurrencyPicker(holder, currency, setCurrency,
{defaultCurrencyLabel: `Default currency (${defaultCurrency})`})
{defaultCurrencyLabel: `Default currency (${defaultCurrency})`, disabled})
),
testId("numeric-currency")
)
]),
cssLabel('Decimals'),
cssRow(
decimals('min', minDecimals, defaultMin, setMinDecimals, testId('numeric-min-decimals')),
decimals('max', maxDecimals, defaultMax, setMaxDecimals, testId('numeric-max-decimals')),
decimals('min', minDecimals, defaultMin, setMinDecimals, disabled, testId('numeric-min-decimals')),
decimals('max', maxDecimals, defaultMax, setMaxDecimals, disabled, testId('numeric-max-decimals')),
),
];
}
@ -107,7 +112,7 @@ export class NumericTextBox extends NTextBox {
function numberOrDefault<T>(value: unknown, def: T): number | T {
return typeof value !== 'undefined' ? Number(value) : def;
return value !== null && value !== undefined ? Number(value) : def;
}
// Helper used by setSave() above to reset some properties when switching modes.
@ -123,9 +128,12 @@ function decimals(
label: string,
value: Observable<number | ''>,
defaultValue: Observable<number>,
setFunc: (val?: number) => void, ...args: DomElementArg[]
setFunc: (val?: number) => void,
disabled: BindableValue<boolean>,
...args: DomElementArg[]
) {
return cssDecimalsBox(
cssDecimalsBox.cls('-disabled', disabled),
cssNumLabel(label),
cssNumInput({type: 'text', size: '2', min: '0'},
dom.prop('value', value),
@ -161,6 +169,10 @@ const cssDecimalsBox = styled('div', `
&:first-child {
margin-right: 16px;
}
&-disabled {
background-color: ${theme.rightPanelToggleButtonDisabledBg};
pointer-events: none;
}
`);
const cssNumLabel = styled('div', `

@ -46,10 +46,14 @@ export class Reference extends NTextBox {
}
public buildTransformConfigDom() {
const disabled = Computed.create(null, use => use(this.field.config.multiselect));
return [
cssLabel('SHOW COLUMN'),
cssRow(
select(this._visibleColRef, this._validCols),
dom.autoDispose(disabled),
select(this._visibleColRef, this._validCols, {
disabled
}),
testId('fbuilder-ref-col-select')
)
];

@ -1,4 +1,4 @@
var _ = require('underscore');
import _ from 'underscore';
/**
* Given a widget name and a type, return the name of the widget that would
@ -15,7 +15,7 @@ var _ = require('underscore');
* }
* }
*/
function getWidgetConfiguration(widgetName, type) {
export function getWidgetConfiguration(widgetName: string, type: string) {
const oneTypeDef = typeDefs[type] || typeDefs.Text;
if (!(widgetName in oneTypeDef.widgets)) {
widgetName = oneTypeDef.default;
@ -25,20 +25,17 @@ function getWidgetConfiguration(widgetName, type) {
config: oneTypeDef.widgets[widgetName]
};
}
exports.getWidgetConfiguration = getWidgetConfiguration;
function mergeOptions(options, type) {
export function mergeOptions(options: any, type: string) {
const {name, config} = getWidgetConfiguration(options.widget, type);
return _.defaults({widget: name}, options, config.options);
}
exports.mergeOptions = mergeOptions;
// Contains the list of types with their storage types, possible widgets, default widgets,
// and defaults for all widget settings
// The names of widgets are used, instead of the actual classes needed, in order to limit
// the spread of dependencies. See ./UserTypeImpl for actual classes.
var typeDefs = {
export const typeDefs: any = {
Any: {
label: 'Any',
icon: 'FieldAny',
@ -48,7 +45,8 @@ var typeDefs = {
editCons: 'TextEditor',
icon: 'FieldTextbox',
options: {
alignment: 'left'
alignment: 'left',
wrap: undefined,
}
}
},
@ -64,6 +62,7 @@ var typeDefs = {
icon: 'FieldTextbox',
options: {
alignment: 'left',
wrap: undefined,
}
},
HyperLink: {
@ -72,6 +71,7 @@ var typeDefs = {
icon: 'FieldLink',
options: {
alignment: 'left',
wrap: undefined,
}
}
},
@ -86,7 +86,13 @@ var typeDefs = {
editCons: 'TextEditor',
icon: 'FieldTextbox',
options: {
alignment: 'right'
alignment: 'right',
wrap: undefined,
decimals: undefined,
maxDecimals: undefined,
numMode: undefined,
numSign: undefined,
currency: undefined,
}
},
Spinner: {
@ -94,7 +100,13 @@ var typeDefs = {
editCons: 'TextEditor',
icon: 'FieldSpinner',
options: {
alignment: 'right'
alignment: 'right',
wrap: undefined,
decimals: undefined,
maxDecimals: undefined,
numMode: undefined,
numSign: undefined,
currency: undefined,
}
}
},
@ -110,7 +122,12 @@ var typeDefs = {
icon: 'FieldTextbox',
options: {
decimals: 0,
alignment: 'right'
alignment: 'right',
wrap: undefined,
maxDecimals: undefined,
numMode: undefined,
numSign: undefined,
currency: undefined,
}
},
Spinner: {
@ -119,7 +136,12 @@ var typeDefs = {
icon: 'FieldSpinner',
options: {
decimals: 0,
alignment: 'right'
alignment: 'right',
wrap: undefined,
maxDecimals: undefined,
numMode: undefined,
numSign: undefined,
currency: undefined,
}
}
},
@ -134,7 +156,8 @@ var typeDefs = {
editCons: 'TextEditor',
icon: 'FieldTextbox',
options: {
alignment: 'center'
alignment: 'center',
wrap: undefined,
}
},
CheckBox: {
@ -198,8 +221,9 @@ var typeDefs = {
icon: 'FieldTextbox',
options: {
alignment: 'left',
choices: null,
choiceOptions: null
wrap: undefined,
choices: undefined,
choiceOptions: undefined
}
}
},
@ -215,8 +239,9 @@ var typeDefs = {
icon: 'FieldTextbox',
options: {
alignment: 'left',
choices: null,
choiceOptions: null
wrap: undefined,
choices: undefined,
choiceOptions: undefined
}
}
},
@ -231,7 +256,8 @@ var typeDefs = {
editCons: 'ReferenceEditor',
icon: 'FieldReference',
options: {
alignment: 'left'
alignment: 'left',
wrap: undefined,
}
}
},
@ -246,7 +272,8 @@ var typeDefs = {
editCons: 'ReferenceListEditor',
icon: 'FieldReference',
options: {
alignment: 'left'
alignment: 'left',
wrap: undefined
}
}
},
@ -268,4 +295,3 @@ var typeDefs = {
default: 'Attachments'
}
};
exports.typeDefs = typeDefs;

@ -1,59 +0,0 @@
const {NTextBox} = require('./NTextBox');
const {NumericTextBox} = require('./NumericTextBox');
const {Spinner} = require('./Spinner');
const {AttachmentsWidget} = require('./AttachmentsWidget');
const {AttachmentsEditor} = require('./AttachmentsEditor');
const UserType = require('./UserType');
const {HyperLinkEditor} = require('./HyperLinkEditor');
const {NTextEditor} = require('./NTextEditor');
const {ReferenceEditor} = require('./ReferenceEditor');
const {ReferenceList} = require('./ReferenceList');
const {ReferenceListEditor} = require('./ReferenceListEditor');
const {HyperLinkTextBox} = require('./HyperLinkTextBox');
const {ChoiceTextBox } = require('./ChoiceTextBox');
const {Reference} = require('./Reference');
/**
* Convert the name of a widget to its implementation.
*/
const nameToWidget = {
'TextBox': NTextBox,
'TextEditor': NTextEditor,
'NumericTextBox': NumericTextBox,
'HyperLinkTextBox': HyperLinkTextBox,
'HyperLinkEditor': HyperLinkEditor,
'Spinner': Spinner,
'CheckBox': require('./CheckBox'),
'CheckBoxEditor': require('./CheckBoxEditor'),
'Reference': Reference,
'Switch': require('./Switch'),
'ReferenceEditor': ReferenceEditor,
'ReferenceList': ReferenceList,
'ReferenceListEditor': ReferenceListEditor,
'ChoiceTextBox': ChoiceTextBox,
'ChoiceEditor': require('./ChoiceEditor'),
'ChoiceListCell': require('./ChoiceListCell').ChoiceListCell,
'ChoiceListEditor': require('./ChoiceListEditor').ChoiceListEditor,
'DateTimeTextBox': require('./DateTimeTextBox'),
'DateTextBox': require('./DateTextBox'),
'DateEditor': require('./DateEditor'),
'AttachmentsWidget': AttachmentsWidget,
'AttachmentsEditor': AttachmentsEditor,
'DateTimeEditor': require('./DateTimeEditor'),
};
exports.nameToWidget = nameToWidget;
/** return a good class to instantiate for viewing a widget/type combination */
function getWidgetConstructor(widget, type) {
const {config} = UserType.getWidgetConfiguration(widget, type);
return nameToWidget[config.cons];
}
exports.getWidgetConstructor = getWidgetConstructor;
/** return a good class to instantiate for editing a widget/type combination */
function getEditorConstructor(widget, type) {
const {config} = UserType.getWidgetConfiguration(widget, type);
return nameToWidget[config.editCons];
}
exports.getEditorConstructor = getEditorConstructor;

@ -0,0 +1,71 @@
import {AttachmentsEditor} from 'app/client/widgets/AttachmentsEditor';
import {AttachmentsWidget} from 'app/client/widgets/AttachmentsWidget';
import CheckBox from 'app/client/widgets/CheckBox';
import CheckBoxEditor from 'app/client/widgets/CheckBoxEditor';
import ChoiceEditor from 'app/client/widgets/ChoiceEditor';
import {ChoiceListCell} from 'app/client/widgets/ChoiceListCell';
import {ChoiceListEditor} from 'app/client/widgets/ChoiceListEditor';
import {ChoiceTextBox} from 'app/client/widgets/ChoiceTextBox';
import DateEditor from 'app/client/widgets/DateEditor';
import DateTextBox from 'app/client/widgets/DateTextBox';
import DateTimeEditor from 'app/client/widgets/DateTimeEditor';
import DateTimeTextBox from 'app/client/widgets/DateTimeTextBox';
import {HyperLinkEditor} from 'app/client/widgets/HyperLinkEditor';
import {HyperLinkTextBox} from 'app/client/widgets/HyperLinkTextBox';
import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
import {NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {NTextEditor} from 'app/client/widgets/NTextEditor';
import {NumericTextBox} from 'app/client/widgets/NumericTextBox';
import {Reference} from 'app/client/widgets/Reference';
import {ReferenceEditor} from 'app/client/widgets/ReferenceEditor';
import {ReferenceList} from 'app/client/widgets/ReferenceList';
import {ReferenceListEditor} from 'app/client/widgets/ReferenceListEditor';
import {Spinner} from 'app/client/widgets/Spinner';
import Switch from 'app/client/widgets/Switch';
import {getWidgetConfiguration} from 'app/client/widgets/UserType';
import {GristType} from 'app/plugin/GristData';
/**
* Convert the name of a widget to its implementation.
*/
export const nameToWidget = {
'TextBox': NTextBox,
'TextEditor': NTextEditor,
'NumericTextBox': NumericTextBox,
'HyperLinkTextBox': HyperLinkTextBox,
'HyperLinkEditor': HyperLinkEditor,
'Spinner': Spinner,
'CheckBox': CheckBox,
'CheckBoxEditor': CheckBoxEditor,
'Reference': Reference,
'Switch': Switch,
'ReferenceEditor': ReferenceEditor,
'ReferenceList': ReferenceList,
'ReferenceListEditor': ReferenceListEditor,
'ChoiceTextBox': ChoiceTextBox,
'ChoiceEditor': ChoiceEditor,
'ChoiceListCell': ChoiceListCell,
'ChoiceListEditor': ChoiceListEditor,
'DateTimeTextBox': DateTimeTextBox,
'DateTextBox': DateTextBox,
'DateEditor': DateEditor,
'AttachmentsWidget': AttachmentsWidget,
'AttachmentsEditor': AttachmentsEditor,
'DateTimeEditor': DateTimeEditor,
};
export interface WidgetConstructor {create: (...args: any[]) => NewAbstractWidget}
/** return a good class to instantiate for viewing a widget/type combination */
export function getWidgetConstructor(widget: string, type: string): WidgetConstructor {
const {config} = getWidgetConfiguration(widget, type as GristType);
return nameToWidget[config.cons as keyof typeof nameToWidget] as any;
}
/** return a good class to instantiate for editing a widget/type combination */
export function getEditorConstructor(widget: string, type: string): typeof NewBaseEditor {
const {config} = getWidgetConfiguration(widget, type as GristType);
return nameToWidget[config.editCons as keyof typeof nameToWidget] as any;
}

@ -30,11 +30,11 @@ export type NumMode = typeof NumMode.type;
export type NumSign = 'parens';
export interface NumberFormatOptions extends FormatOptions {
numMode?: NumMode;
numSign?: NumSign;
decimals?: number; // aka minimum fraction digits
maxDecimals?: number;
currency?: string;
numMode?: NumMode|null;
numSign?: NumSign|null;
decimals?: number|null; // aka minimum fraction digits
maxDecimals?: number|null;
currency?: string|null;
}
export function getCurrency(options: NumberFormatOptions, docSettings: DocumentSettings): string {
@ -44,11 +44,10 @@ export function getCurrency(options: NumberFormatOptions, docSettings: DocumentS
export function buildNumberFormat(options: NumberFormatOptions, docSettings: DocumentSettings): Intl.NumberFormat {
const currency = getCurrency(options, docSettings);
const nfOptions: Intl.NumberFormatOptions = parseNumMode(options.numMode, currency);
// numSign is implemented outside of Intl.NumberFormat since the latter's similar 'currencySign'
// option is not well-supported, and doesn't apply to non-currency formats.
if (options.decimals !== undefined) {
if (options.decimals !== undefined && options.decimals !== null) {
// Should be at least 0
nfOptions.minimumFractionDigits = clamp(Number(options.decimals), 0, 20);
}
@ -57,7 +56,7 @@ export function buildNumberFormat(options: NumberFormatOptions, docSettings: Doc
// implied by numMode.
const tmp = new Intl.NumberFormat(docSettings.locale, nfOptions).resolvedOptions();
if (options.maxDecimals !== undefined) {
if (options.maxDecimals !== undefined && options.maxDecimals !== null) {
// Should be at least 0 and at least minimumFractionDigits.
nfOptions.maximumFractionDigits = clamp(Number(options.maxDecimals), tmp.minimumFractionDigits || 0, 20);
} else if (!options.numMode) {
@ -80,7 +79,7 @@ const currencyDisplay = (function(){
}
})();
export function parseNumMode(numMode?: NumMode, currency?: string): Intl.NumberFormatOptions {
export function parseNumMode(numMode?: NumMode|null, currency?: string): Intl.NumberFormatOptions {
switch (numMode) {
case 'currency': return {style: 'currency', currency, currencyDisplay};
case 'decimal': return {useGrouping: true};

@ -959,3 +959,21 @@ export function assertIsDefined<T>(name: string, value: T): asserts value is Non
return await fn();
}
}
/**
* Checks if value is 'empty' (like null, undefined, empty string, empty array/set/map, empty object).
* Values like 0, true, false are not empty.
*/
export function notSet(value: any) {
return value === undefined || value === null || value === ''
|| (Array.isArray(value) && !value.length)
|| (typeof value === 'object' && !Object.keys(value).length)
|| (['[object Map]', '[object Set'].includes(value.toString()) && !value.size);
}
/**
* Checks if value is 'empty', if it is, returns the default value (which is null).
*/
export function ifNotSet(value: any, def: any = null) {
return notSet(value) ? def : value;
}

@ -229,7 +229,7 @@ export const GristDark: ThemeColors = {
'right-panel-toggle-button-enabled-bg': '#555563',
'right-panel-toggle-button-enabled-hover-fg': '#D9D9D9',
'right-panel-toggle-button-disabled-fg': '#FFFFFF',
'right-panel-toggle-button-disabled-bg': '#E8E8E8',
'right-panel-toggle-button-disabled-bg': '#333333',
'right-panel-field-settings-bg': '#414358',
'right-panel-field-settings-button-bg': '#57575F',

@ -224,7 +224,7 @@ export const GristLight: ThemeColors = {
'right-panel-subtab-selected-underline': '#16B378',
'right-panel-subtab-hover-fg': '#009058',
'right-panel-subtab-hover-underline': '#16B378',
'right-panel-disabled-overlay': 'white',
'right-panel-disabled-overlay': '#F7F7F7',
'right-panel-toggle-button-enabled-fg': '#FFFFFF',
'right-panel-toggle-button-enabled-bg': '#262633',
'right-panel-toggle-button-enabled-hover-fg': '#D9D9D9',

File diff suppressed because it is too large Load Diff

@ -14,6 +14,7 @@ import * as path from 'path';
import { decodeUrl } from 'app/common/gristUrls';
import { FullUser, UserProfile } from 'app/common/LoginSessionAPI';
import { resetOrg } from 'app/common/resetOrg';
import { UserAction } from 'app/common/DocActions';
import { TestState } from 'app/common/TestState';
import { Organization as APIOrganization, DocStateComparison, UserAPIImpl, Workspace } from 'app/common/UserAPI';
import { Organization } from 'app/gen-server/entity/Organization';
@ -838,6 +839,16 @@ export async function waitForServer(optTimeout: number = 2000) {
));
}
/**
* Sends UserActions using client api from the browser.
*/
export async function sendActions(actions: UserAction[]) {
await driver.executeScript(`
gristDocPageModel.gristDoc.get().docModel.docData.sendActions(${JSON.stringify(actions)});
`);
await waitForServer();
}
/**
* Returns the left-panel item for the given page, given by a full string name, or a RegExp.
* You may simply click it to switch to that page.
@ -1392,14 +1403,22 @@ export function openColumnMenu(col: IColHeader|string, option?: string): WebElem
/**
* Sets the type of the currently selected field to value.
*/
export async function setType(type: RegExp, options: {skipWait?: boolean} = {}) {
export async function setType(type: RegExp|string, options: {skipWait?: boolean} = {}) {
await toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-field').click();
await driver.find('.test-fbuilder-type-select').click();
type = typeof type === 'string' ? exactMatch(type) : type;
await driver.findContentWait('.test-select-menu .test-select-row', type, 500).click();
if (!options.skipWait) { await waitForServer(); }
}
/**
* Gets the type of the currently selected field.
*/
export async function getType() {
return await driver.find('.test-fbuilder-type-select').getText();
}
export async function applyTypeTransform() {
await driver.findContent('.type_transform_prompt button', /Apply/).click();
}
@ -1963,6 +1982,35 @@ export async function setColor(colorInputEl: WebElement, color: string) {
}, colorInputEl, color);
}
export function setTextColor(color: string) {
return setColor(driver.find('.test-text-input'), color);
}
export function setFillColor(color: string) {
return setColor(driver.find('.test-fill-input'), color);
}
export function openColorPicker() {
return driver.find('.test-color-select').click();
}
export async function assertTextColor(cell: WebElement, color: string) {
color = color.startsWith('#') ? hexToRgb(color) : color;
const test = async () => {
const actual = await cell.getCssValue('color');
assert.equal(actual, color);
};
await waitToPass(test, 500);
}
export async function assertFillColor(cell: WebElement, color: string) {
color = color.startsWith('#') ? hexToRgb(color) : color;
const test = async () => {
const actual = await cell.getCssValue('background-color');
assert.equal(actual, color);
};
await waitToPass(test, 500);
}
// the rgbToHex function is from this conversation: https://stackoverflow.com/a/5624139/8728791
export function rgbToHex(color: string) {
@ -1989,7 +2037,7 @@ export function hexToRgb(hex: string) {
* Adds new column to the table.
* @param name Name of the column
*/
export async function addColumn(name: string) {
export async function addColumn(name: string, type?: string) {
await scrollIntoView(await driver.find('.active_section .mod-add-column'));
await driver.find('.active_section .mod-add-column').click();
// If we are on a summary table, we could be see a menu helper
@ -2002,6 +2050,9 @@ export async function addColumn(name: string) {
await driver.sendKeys(name);
await driver.sendKeys(Key.ENTER);
await waitForServer();
if (type) {
await setType(exactMatch(type));
}
}
export async function showColumn(name: string) {
@ -2019,6 +2070,14 @@ export async function selectColumnRange(col1: string, col2: string) {
await driver.mouseUp();
}
export async function selectGrid() {
await driver.find(".gridview_data_corner_overlay").click();
}
export async function selectColumn(col: string) {
await getColumnHeader({col}).click();
}
export interface WindowDimensions {
width: number;
height: number;
@ -2213,7 +2272,16 @@ export async function getDateFormat(): Promise<string> {
*/
export async function setDateFormat(format: string|RegExp) {
await driver.find('[data-test-id=Widget_dateFormat]').click();
await driver.findContentWait('.test-select-menu .test-select-row', format, 200).click();
await driver.findContentWait('.test-select-menu .test-select-row',
typeof format === 'string' ? exactMatch(format) : format, 200).click();
await waitForServer();
}
export async function setCustomDateFormat(format: string) {
await setDateFormat("Custom");
await driver.find('[data-test-id=Widget_dateCustomFormat]').click();
await selectAll();
await driver.sendKeys(format, Key.ENTER);
await waitForServer();
}
@ -2474,6 +2542,25 @@ export async function setWidgetUrl(url: string) {
await waitForServer();
}
/**
* Opens a behavior menu and clicks one of the option.
*/
export async function changeBehavior(option: string|RegExp) {
await driver.find('.test-field-behaviour').click();
await driver.findContent('.grist-floating-menu li', option).click();
await waitForServer();
}
/**
* Gets all available options in the behavior menu.
*/
export async function availableBehaviorOptions() {
await driver.find('.test-field-behaviour').click();
const list = await driver.findAll('.grist-floating-menu li', el => el.getText());
await driver.sendKeys(Key.ESCAPE);
return list;
}
} // end of namespace gristUtils
stackWrapOwnMethods(gristUtils);

Loading…
Cancel
Save