(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
This commit is contained in:
Jarosław Sadziński 2022-10-14 12:07:19 +02:00
parent ab3cdb62ac
commit 8be920dd25
36 changed files with 2579 additions and 395 deletions

View File

@ -183,6 +183,9 @@ function BaseView(gristDoc, viewSectionModel, options) {
this.fieldBuilders.at(this.cursor.fieldIndex()) 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 // 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 can only be true for on-demand tables).
this.isTruncated = ko.observable(false); this.isTruncated = ko.observable(false);

View File

@ -86,6 +86,16 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
this.cellSelector, this.tableModel.tableData, this.sortedRows, this.viewSection.viewFields); this.cellSelector, this.tableModel.tableData, this.sortedRows, this.viewSection.viewFields);
this.colMenuTargets = {}; // Reference from column ref to its menu target dom 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 // Cache of column right offsets, used to determine the col select range
this.colRightOffsets = this.autoDispose(ko.computed(() => { this.colRightOffsets = this.autoDispose(ko.computed(() => {
let fields = this.viewSection.viewFields(); let fields = this.viewSection.viewFields();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import {formatterForRec} from 'app/client/models/entities/ColumnRec';
import * as modelUtil from 'app/client/models/modelUtil'; import * as modelUtil from 'app/client/models/modelUtil';
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner'; import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
import {Style} from 'app/client/models/Styles'; import {Style} from 'app/client/models/Styles';
import {ViewFieldConfig} from 'app/client/models/ViewFieldConfig';
import * as UserType from 'app/client/widgets/UserType'; import * as UserType from 'app/client/widgets/UserType';
import {DocumentSettings} from 'app/common/DocumentSettings'; import {DocumentSettings} from 'app/common/DocumentSettings';
import {BaseFormatter} from 'app/common/ValueFormatter'; 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. // which takes into account the default options for column's type.
widgetOptionsJson: modelUtil.SaveableObjObservable<any>; widgetOptionsJson: modelUtil.SaveableObjObservable<any>;
// Whether lines should wrap in a cell.
wrapping: ko.Computed<boolean>;
disableModify: ko.Computed<boolean>; disableModify: ko.Computed<boolean>;
disableEditData: 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>; textColor: modelUtil.KoSaveableObservable<string|undefined>;
fillColor: modelUtil.KoSaveableObservable<string|undefined>; fillColor: modelUtil.KoSaveableObservable<string|undefined>;
fontBold: modelUtil.KoSaveableObservable<boolean|undefined>; fontBold: modelUtil.KoSaveableObservable<boolean|undefined>;
fontUnderline: modelUtil.KoSaveableObservable<boolean|undefined>; fontUnderline: modelUtil.KoSaveableObservable<boolean|undefined>;
fontItalic: modelUtil.KoSaveableObservable<boolean|undefined>; fontItalic: modelUtil.KoSaveableObservable<boolean|undefined>;
fontStrikethrough: 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>; 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. // Helper which adds/removes/updates field's displayCol to match the formula.
saveDisplayFormula(formula: string): Promise<void>|undefined; 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 { 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). // 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 // During transform, use the transform column's options (which should be initialized to match
// field or column when the transform starts TODO). // 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 // Helper that returns the RowModel for either this field or its column, depending on
// useColOptions. Field and Column have a few identical fields: // useColOptions. Field and Column have a few identical fields:
// .widgetOptions() // JSON string of options // .widgetOptions() // JSON string of options
// .saveDisplayFormula() // Method to save the display formula // .saveDisplayFormula() // Method to save the display formula
// .displayCol() // Reference to an optional associated display column. // .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. // 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({ this.visibleColRef = modelUtil.addSaveInterface(ko.pureComputed({
read: () => this._fieldOrColumn().visibleCol(), 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. // 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(), read: () => this._fieldOrColumn().widgetOptions(),
write: (setter, val) => setter(this._fieldOrColumn().widgetOptions, val) write: (setter, val) => setter(this._fieldOrColumn().widgetOptions, val)
}); }));
// Observable for the object with the current options, either for the field or for the column, // 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. // which takes into account the default options for this column's type.
this.widgetOptionsJson = modelUtil.jsonObservable(this._widgetOptionsStr, this.widgetOptionsJson = this.autoDispose(modelUtil.jsonObservable(this._widgetOptionsStr,
(opts: any) => UserType.mergeOptions(opts || {}, this.column().pureType())); (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());
// 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.textColor = this.widgetOptionsJson.prop('textColor');
this.fillColor = this.widgetOptionsJson.prop('fillColor'); this.fillColor = this.widgetOptionsJson.prop('fillColor');
this.fontBold = this.widgetOptionsJson.prop('fontBold'); 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.fontStrikethrough = this.widgetOptionsJson.prop('fontStrikethrough');
this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson()); this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson());
this.style = ko.pureComputed({
this.updateChoices = async (renames, widgetOptions) => { read: () => ({
// In case this column is being transformed - using Apply Formula to Data, bundle the action textColor: this.textColor(),
// together with the transformation. fillColor: this.fillColor(),
const actionOptions = {nestInActiveBundle: this.column.peek().isTransforming.peek()}; fontBold: this.fontBold(),
const hasRenames = !!Object.entries(renames).length; fontUnderline: this.fontUnderline(),
const callback = async () => { fontItalic: this.fontItalic(),
await Promise.all([ fontStrikethrough: this.fontStrikethrough(),
this.widgetOptionsJson.setAndSave(widgetOptions), }) as Style,
hasRenames ? write: (style: Style) => {
docModel.docData.sendAction(["RenameChoices", this.column().table().tableId(), this.colId(), renames]) : this.widgetOptionsJson.update(style);
null },
]); });
};
return docModel.docData.bundleActions("Update choices configuration", callback, actionOptions);
};
this.tableId = ko.pureComputed(() => this.column().table().tableId()); this.tableId = ko.pureComputed(() => this.column().table().tableId());
this.rulesCols = refListRecords(docModel.columns, ko.pureComputed(() => this._fieldOrColumn().rules())); 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); 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()));
} }

View File

@ -23,6 +23,7 @@ import {arrayRepeat} from 'app/common/gutil';
import {Sort} from 'app/common/SortSpec'; import {Sort} from 'app/common/SortSpec';
import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI'; import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI';
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap'; import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
import {BEHAVIOR} from 'app/client/models/entities/ColumnRec';
import {removeRule, RuleOwner} from 'app/client/models/RuleOwner'; import {removeRule, RuleOwner} from 'app/client/models/RuleOwner';
import {Computed, Holder, Observable} from 'grainjs'; import {Computed, Holder, Observable} from 'grainjs';
import * as ko from 'knockout'; import * as ko from 'knockout';
@ -172,6 +173,18 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
editingFormula: ko.Computed<boolean>; 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. // Save all filters of fields/columns in the section.
saveFilters(): Promise<void>; saveFilters(): Promise<void>;
@ -277,6 +290,25 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
sectionId: customDefObj.prop('sectionId') 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.activeCustomOptions = modelUtil.customValue(this.customDef.widgetOptions);
this.saveCustomDef = async () => { this.saveCustomDef = async () => {

View File

@ -1,6 +1,6 @@
import {CursorPos} from 'app/client/components/Cursor'; import {CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc'; 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 {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles'; import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas'; import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas';
@ -16,7 +16,12 @@ import {bundleChanges, Computed, dom, DomContents, DomElementArg, fromKo, MultiH
Observable, styled} from 'grainjs'; Observable, styled} from 'grainjs';
import * as ko from 'knockout'; 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 untieColId = origColumn.untieColIdFromLabel;
const editedLabel = Observable.create(owner, ''); 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 [ return [
cssLabel('COLUMN LABEL AND ID'), cssLabel('COLUMN LABEL AND ID'),
cssRow( cssRow(
@ -45,12 +56,13 @@ export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, curso
editor = cssInput(fromKo(origColumn.label), editor = cssInput(fromKo(origColumn.label),
async val => { await origColumn.label.saveOnly(val); editedLabel.set(''); }, async val => { await origColumn.label.saveOnly(val); editedLabel.set(''); },
dom.on('input', (ev, elem) => { if (!untieColId.peek()) { editedLabel.set(elem.value); } }), 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'), testId('field-label'),
), ),
cssInput(editableColId, cssInput(editableColId,
saveColId, 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(''), cssCodeBlock.cls(''),
{style: 'margin-top: 8px'}, {style: 'margin-top: 8px'},
testId('field-col-id'), testId('field-col-id'),
@ -60,8 +72,8 @@ export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, curso
cssColTieConnectors(), cssColTieConnectors(),
cssToggleButton(icon('FieldReference'), cssToggleButton(icon('FieldReference'),
cssToggleButton.cls('-selected', (use) => !use(untieColId)), cssToggleButton.cls('-selected', (use) => !use(untieColId)),
dom.on('click', () => !origColumn.disableModify.peek() && untieColId.saveOnly(!untieColId.peek())), dom.on('click', toggleUntieColId),
cssToggleButton.cls("-disabled", origColumn.disableModify), cssToggleButton.cls("-disabled", use => use(origColumn.disableModify) || use(disabled)),
testId('field-derive-id') testId('field-derive-id')
), ),
) )
@ -78,12 +90,13 @@ type BuildEditor = (
onSave?: SaveHandler, onSave?: SaveHandler,
onCancel?: () => void) => void; onCancel?: () => void) => void;
type BEHAVIOR = "empty"|"formula"|"data";
export function buildFormulaConfig( export function buildFormulaConfig(
owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor 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 // Intermediate state - user wants to specify formula, but haven't done yet
const maybeFormula = Observable.create(owner, false); const maybeFormula = Observable.create(owner, false);
@ -93,7 +106,7 @@ export function buildFormulaConfig(
// If this column belongs to a summary table. // If this column belongs to a summary table.
const isSummaryTable = Computed.create(owner, use => Boolean(use(use(origColumn.table).summarySourceTable))); 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 == '' // - empty: isFormula and formula == ''
// - formula: isFormula and formula != '' // - formula: isFormula and formula != ''
// - data: not isFormula nd formula == '' // - data: not isFormula nd formula == ''
@ -123,31 +136,89 @@ export function buildFormulaConfig(
owner.autoDispose(origColumn.formula.subscribe(clearState)); owner.autoDispose(origColumn.formula.subscribe(clearState));
owner.autoDispose(origColumn.isFormula.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 // Menu helper that will show normal menu with some default options
const menu = (label: DomContents, options: DomElementArg[]) => const menu = (label: DomContents, options: DomElementArg[]) =>
cssRow( cssRow(
selectMenu( selectMenu(
label, label,
() => options, () => !isMultiSelect.get() ? options : [
isFormulaLike.get() ? convertToDataAll() : null,
clearAndResetAll(),
],
testId("field-behaviour"), testId("field-behaviour"),
// HACK: Menu helper will add tabindex to this element, which will make // HACK: Menu helper will add tabindex to this element, which will make
// this element focusable and will steal focus from clipboard. This in turn, // this element focusable and will steal focus from clipboard. This in turn,
// will not dispose the formula editor when menu is clicked. // will not dispose the formula editor when menu is clicked.
(el) => el.removeAttribute("tabindex"), (el) => el.removeAttribute("tabindex"),
dom.cls(cssBlockedCursor.className, origColumn.disableModify), dom.cls(cssBlockedCursor.className, disableModify),
dom.cls("disabled", origColumn.disableModify)), dom.cls("disabled", disableModify)),
); );
// Behaviour label
// Behavior label
const behaviorName = Computed.create(owner, behavior, (use, type) => { const behaviorName = Computed.create(owner, behavior, (use, type) => {
if (type === 'formula') { return "Formula Column"; } if (use(isMultiSelect)) {
if (type === 'data') { return "Data Column"; } const commonType = use(multiType);
return "Empty Column"; 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) => { 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: // 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); const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);
// Helper that will create different flavors for formula builder. // Helper that will create different flavors for formula builder.
const formulaBuilder = (onSave: SaveHandler) => [ const formulaBuilder = (onSave: SaveHandler) => [
@ -233,6 +309,7 @@ export function buildFormulaConfig(
origColumn, origColumn,
buildEditor, buildEditor,
"Enter formula", "Enter formula",
disableOtherActions,
onSave, onSave,
clearState)), clearState)),
dom.maybe(errorMessage, errMsg => cssRow(cssError(errMsg), testId('field-error-count'))), dom.maybe(errorMessage, errMsg => cssRow(cssError(errMsg), testId('field-error-count'))),
@ -241,30 +318,30 @@ export function buildFormulaConfig(
return dom.maybe(behavior, (type: BEHAVIOR) => [ return dom.maybe(behavior, (type: BEHAVIOR) => [
cssLabel('COLUMN BEHAVIOR'), cssLabel('COLUMN BEHAVIOR'),
...(type === "empty" ? [ ...(type === "empty" ? [
menu(behaviourLabel(), [ menu(behaviorLabel(), [
convertToDataOption(), convertToDataOption(),
]), ]),
cssEmptySeparator(), cssEmptySeparator(),
cssRow(textButton( cssRow(textButton(
"Set formula", "Set formula",
dom.on("click", setFormula), dom.on("click", setFormula),
dom.prop("disabled", origColumn.disableModify), dom.prop("disabled", disableOtherActions),
testId("field-set-formula") testId("field-set-formula")
)), )),
cssRow(textButton( cssRow(textButton(
"Set trigger formula", "Set trigger formula",
dom.on("click", setTrigger), 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") testId("field-set-trigger")
)), )),
cssRow(textButton( cssRow(textButton(
"Make into data column", "Make into data column",
dom.on("click", convertToData), 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") testId("field-set-data")
)) ))
] : type === "formula" ? [ ] : type === "formula" ? [
menu(behaviourLabel(), [ menu(behaviorLabel(), [
convertToDataOption(), convertToDataOption(),
clearAndResetOption(), clearAndResetOption(),
]), ]),
@ -274,11 +351,11 @@ export function buildFormulaConfig(
"Convert to trigger formula", "Convert to trigger formula",
dom.on("click", convertFormulaToTrigger), dom.on("click", convertFormulaToTrigger),
dom.hide(maybeFormula), dom.hide(maybeFormula),
dom.prop("disabled", use => use(isSummaryTable) || use(origColumn.disableModify)), dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
testId("field-set-trigger") testId("field-set-trigger")
)) ))
] : /* type == 'data' */ [ ] : /* type == 'data' */ [
menu(behaviourLabel(), menu(behaviorLabel(),
[ [
dom.domComputed(origColumn.hasTriggerFormula, (hasTrigger) => hasTrigger ? dom.domComputed(origColumn.hasTriggerFormula, (hasTrigger) => hasTrigger ?
// If we have trigger, we will convert it directly to a formula column // 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), () => [ dom.maybe((use) => use(maybeTrigger) || use(origColumn.hasTriggerFormula), () => [
cssLabel('TRIGGER FORMULA'), cssLabel('TRIGGER FORMULA'),
formulaBuilder(onSaveConvertToTrigger), formulaBuilder(onSaveConvertToTrigger),
dom.create(buildFormulaTriggers, origColumn, maybeTrigger) dom.create(buildFormulaTriggers, origColumn, {
disabled: disableOtherActions,
notTrigger: maybeTrigger,
})
]), ]),
// Else offer a way to convert to trigger formula. // Else offer a way to convert to trigger formula.
dom.maybe((use) => !(use(maybeTrigger) || use(origColumn.hasTriggerFormula)), () => [ dom.maybe((use) => !(use(maybeTrigger) || use(origColumn.hasTriggerFormula)), () => [
@ -301,7 +381,7 @@ export function buildFormulaConfig(
cssRow(textButton( cssRow(textButton(
"Set trigger formula", "Set trigger formula",
dom.on("click", convertDataColumnToTriggerColumn), dom.on("click", convertDataColumnToTriggerColumn),
dom.prop("disabled", origColumn.disableModify), dom.prop("disabled", disableOtherActions),
testId("field-set-trigger") testId("field-set-trigger")
)) ))
]) ])
@ -313,11 +393,12 @@ function buildFormula(
column: ColumnRec, column: ColumnRec,
buildEditor: BuildEditor, buildEditor: BuildEditor,
placeholder: string, placeholder: string,
disabled: Observable<boolean>,
onSave?: SaveHandler, onSave?: SaveHandler,
onCancel?: () => void) { onCancel?: () => void) {
return cssFieldFormula(column.formula, {placeholder, maxLines: 2}, return cssFieldFormula(column.formula, {placeholder, maxLines: 2},
dom.cls('formula_field_sidepane'), dom.cls('formula_field_sidepane'),
cssFieldFormula.cls('-disabled', column.disableModify), cssFieldFormula.cls('-disabled', disabled),
cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)), cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)),
dom.cls('disabled'), dom.cls('disabled'),
{tabIndex: '-1'}, {tabIndex: '-1'},

View File

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

View File

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

View File

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

View File

@ -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 * A "light" style is supported in CSS by passing cssButtonSelect.cls('-light') as an additional
* argument. * argument.
* *
* A disabled state is supported by passing cssButtonSelect.cls('-disabled').
*
* Usage: * Usage:
* const fruit = observable("apple"); * const fruit = observable("apple");
* buttonSelect(fruit, ["apple", "banana", "mango"]); * buttonSelect(fruit, ["apple", "banana", "mango"]);
@ -61,13 +63,13 @@ export function buttonToggleSelect<T>(
/** /**
* Pre-made text alignment selector. * Pre-made text alignment selector.
*/ */
export function alignmentSelect(obs: Observable<string>) { export function alignmentSelect(obs: Observable<string>, ...domArgs: DomElementArg[]) {
const alignments: Array<ISelectorOption<string>> = [ const alignments: Array<ISelectorOption<string>> = [
{value: 'left', icon: 'LeftAlign'}, {value: 'left', icon: 'LeftAlign'},
{value: 'center', icon: 'CenterAlign'}, {value: 'center', icon: 'CenterAlign'},
{value: 'right', icon: 'RightAlign'} {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; border: none;
background-color: ${theme.hover}; 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', ` const cssSelectorLabel = styled('span', `

View File

@ -112,15 +112,24 @@ export class AttachmentsWidget extends NewAbstractWidget {
} }
public buildConfigDom(): Element { public buildConfigDom(): Element {
const inputRange = input(fromKo(this._height), {onInput: true}, { const options = this.field.config.options;
style: 'margin: 0 5px;', const height = options.prop('height');
type: 'range', const inputRange = input(
min: '16', fromKo(height),
max: '96', {onInput: true}, {
value: '36' style: 'margin: 0 5px;',
}, testId('thumbnail-size')); 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) // 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( return cssRow(
sizeLabel('Size'), sizeLabel('Size'),
inputRange inputRange

View File

@ -1,11 +1,12 @@
import {allCommands} from 'app/client/components/commands'; import {allCommands} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {Style} from 'app/client/models/Styles';
import {textButton} from 'app/client/ui2018/buttons'; import {textButton} from 'app/client/ui2018/buttons';
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect'; import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
import {theme, vars} from 'app/client/ui2018/cssVars'; import {theme, vars} from 'app/client/ui2018/cssVars';
import {ConditionalStyle} from 'app/client/widgets/ConditionalStyle'; 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 { export class CellStyle extends Disposable {
private _textColor: Observable<string|undefined>; private _textColor: Observable<string|undefined>;
@ -21,16 +22,29 @@ export class CellStyle extends Disposable {
private _defaultTextColor: string private _defaultTextColor: string
) { ) {
super(); super();
this._textColor = fromKo(this._field.textColor); this._textColor = fromKo(this._field.config.textColor);
this._fillColor = fromKo(this._field.fillColor); this._fillColor = fromKo(this._field.config.fillColor);
this._fontBold = fromKo(this._field.fontBold); this._fontBold = fromKo(this._field.config.fontBold);
this._fontUnderline = fromKo(this._field.fontUnderline); this._fontUnderline = fromKo(this._field.config.fontUnderline);
this._fontItalic = fromKo(this._field.fontItalic); this._fontItalic = fromKo(this._field.config.fontItalic);
this._fontStrikethrough = fromKo(this._field.fontStrikethrough); this._fontStrikethrough = fromKo(this._field.config.fontStrikethrough);
} }
public buildDom(): DomContents { public buildDom(): DomContents {
const holder = new MultiHolder(); 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 [ return [
cssLine( cssLine(
cssLabel('CELL STYLE', dom.autoDispose(holder)), cssLabel('CELL STYLE', dom.autoDispose(holder)),
@ -49,12 +63,16 @@ export class CellStyle extends Disposable {
fontItalic: this._fontItalic, fontItalic: this._fontItalic,
fontUnderline: this._fontUnderline, fontUnderline: this._fontUnderline,
fontStrikethrough: this._fontStrikethrough fontStrikethrough: this._fontStrikethrough
}, }, {
// Calling `field.widgetOptionsJson.save()` saves both fill and text color settings. // Calling `field.config.options.save()` saves all options at once.
() => this._field.widgetOptionsJson.save() 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))
]; ];
} }
} }

View File

@ -1,5 +1,6 @@
import {createGroup} from 'app/client/components/commands'; 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 {IAutocompleteOptions} from 'app/client/lib/autocomplete';
import {IToken, TokenField, tokenFieldStyles} from 'app/client/lib/TokenField'; import {IToken, TokenField, tokenFieldStyles} from 'app/client/lib/TokenField';
import {colors, testId, theme} from 'app/client/ui2018/cssVars'; 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 {csvEncodeRow} from 'app/common/csvFormat';
import {CellValue} from "app/common/DocActions"; import {CellValue} from "app/common/DocActions";
import {decodeObject, encodeObject} from 'app/plugin/objtypes'; import {decodeObject, encodeObject} from 'app/plugin/objtypes';
import {dom, styled} from 'grainjs';
import {ChoiceOptions, getRenderFillColor, getRenderTextColor} from 'app/client/widgets/ChoiceTextBox'; import {ChoiceOptions, getRenderFillColor, getRenderTextColor} from 'app/client/widgets/ChoiceTextBox';
import {choiceToken, cssChoiceACItem, cssChoiceToken} from 'app/client/widgets/ChoiceToken'; import {choiceToken, cssChoiceACItem, cssChoiceToken} from 'app/client/widgets/ChoiceToken';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {dom, styled} from 'grainjs';
export class ChoiceItem implements ACItem, IToken { export class ChoiceItem implements ACItem, IToken {
public cleanText: string = normalizeText(this.label); public cleanText: string = normalizeText(this.label);

View File

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

View File

@ -7,7 +7,7 @@ import {testId} from 'app/client/ui2018/cssVars';
import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry'; import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry';
import {choiceToken, DEFAULT_FILL_COLOR, DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken'; import {choiceToken, DEFAULT_FILL_COLOR, DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';
import {NTextBox} from 'app/client/widgets/NTextBox'; 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 IChoiceOptions = Style
export type ChoiceOptions = Record<string, IChoiceOptions | undefined>; export type ChoiceOptions = Record<string, IChoiceOptions | undefined>;
@ -66,16 +66,29 @@ export class ChoiceTextBox extends NTextBox {
} }
public buildConfigDom() { 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 [ return [
super.buildConfigDom(), super.buildConfigDom(),
cssLabel('CHOICES'), cssLabel('CHOICES'),
cssRow( cssRow(
dom.autoDispose(disabled),
dom.autoDispose(mixed),
dom.create( dom.create(
ChoiceListEntry, ChoiceListEntry,
this._choiceValues, this._choiceValues,
this._choiceOptionsByName, this._choiceOptionsByName,
this.save.bind(this), 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>) { protected save(choices: string[], choiceOptions: ChoiceOptionsByName, renames: Record<string, string>) {
const options = { const options = {
...this.options.peek(),
choices, choices,
choiceOptions: toObject(choiceOptions) choiceOptions: toObject(choiceOptions)
}; };
return this.field.updateChoices(renames, options); return this.field.config.updateChoices(renames, options);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ import { SaveableObjObservable, setSaveValue } from 'app/client/models/modelUtil
import { CombinedStyle, Style } from 'app/client/models/Styles'; import { CombinedStyle, Style } from 'app/client/models/Styles';
import { FieldSettingsMenu } from 'app/client/ui/FieldMenus'; import { FieldSettingsMenu } from 'app/client/ui/FieldMenus';
import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanelStyles'; 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 { theme } from 'app/client/ui2018/cssVars';
import { IOptionFull, menu, select } from 'app/client/ui2018/menus'; import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
import { DiffBox } from 'app/client/widgets/DiffBox'; 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 { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes';
import { CellValue } from 'app/plugin/GristData'; import { CellValue } from 'app/plugin/GristData';
import { Computed, Disposable, fromKo, dom as grainjsDom, 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 ko from 'knockout';
import * as _ from 'underscore'; import * as _ from 'underscore';
@ -156,22 +156,7 @@ export class FieldBuilder extends Disposable {
} }
})); }));
this.widget = ko.pureComputed<object>({ this.widget = ko.pureComputed(() => this.field.widget());
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);
}
});
// Whether there is a pending call that transforms column. // Whether there is a pending call that transforms column.
this.isCallPending = ko.observable(false); this.isCallPending = ko.observable(false);
@ -221,11 +206,36 @@ export class FieldBuilder extends Disposable {
value: label, value: label,
icon: typeWidgets[label].icon 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'), cssLabel('CELL FORMAT'),
cssRow( cssRow(
widgetOptions.length <= 2 ? buttonSelect(fromKo(this.widget), widgetOptions) : grainjsDom.autoDispose(defaultWidget),
select(fromKo(this.widget), widgetOptions), widgetOptions.length <= 2 ?
buttonSelect(
fromKo(this.field.config.widget),
widgetOptions,
cssButtonSelect.cls("-disabled", disabled),
) :
select(
defaultWidget,
widgetOptions,
{
disabled,
defaultLabel: 'Mixed format'
}
),
testId('widget-select') testId('widget-select')
) )
]; ];
@ -236,21 +246,42 @@ export class FieldBuilder extends Disposable {
* Build the type change dom. * Build the type change dom.
*/ */
public buildSelectTypeDom() { public buildSelectTypeDom() {
const selectType = Computed.create(null, (use) => use(fromKo(this._readOnlyPureType))); const holder = new MultiHolder();
selectType.onWrite(newType => newType === this._readOnlyPureType.peek() || this._setType(newType)); 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 onDispose = () => (this.isDisposed() || selectType.set(this.field.column().pureType()));
const allFormulas = Computed.create(holder, use => use(use(this.field.viewSection).columnsAllIsFormula));
return [ return [
cssRow( cssRow(
grainjsDom.autoDispose(selectType), grainjsDom.autoDispose(holder),
select(selectType, this._availableTypes, { 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), use(this.isCallPending),
menuCssClass: cssTypeSelectMenu.className, menuCssClass: cssTypeSelectMenu.className,
defaultLabel: 'Mixed types'
}), }),
testId('type-select'), testId('type-select'),
grainjsDom.cls('tour-type-selector'), 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((use) => use(this._isRef) && !use(this._isTransformingType), () => this._buildRefTableSelect()),
grainjsDom.maybe(this._isTransformingType, () => { grainjsDom.maybe(this._isTransformingType, () => {
@ -273,9 +304,19 @@ export class FieldBuilder extends Disposable {
public _setType(newType: string): Promise<unknown>|undefined { public _setType(newType: string): Promise<unknown>|undefined {
if (this.origColumn.isFormula()) { if (this.origColumn.isFormula()) {
// Do not type transform a new/empty column or a formula column. Just make a best guess for // 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(); 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) { } else if (!this.columnTransform) {
this.columnTransform = TypeTransform.create(null, this.gristDoc, this); this.columnTransform = TypeTransform.create(null, this.gristDoc, this);
return this.columnTransform.prepare(newType); return this.columnTransform.prepare(newType);
@ -295,14 +336,18 @@ export class FieldBuilder extends Disposable {
icon: 'FieldTable' as const icon: 'FieldTable' as const
})) }))
); );
const isDisabled = Computed.create(null, use => {
return use(this.origColumn.disableModifyBase) || use(this.field.config.multiselect);
});
return [ return [
cssLabel('DATA FROM TABLE'), cssLabel('DATA FROM TABLE'),
cssRow( cssRow(
dom.autoDispose(allTables), dom.autoDispose(allTables),
dom.autoDispose(isDisabled),
select(fromKo(this._refTableId), allTables, { select(fromKo(this._refTableId), allTables, {
// Disallow changing the destination table when the column should not be modified // Disallow changing the destination table when the column should not be modified
// (specifically when it's a group-by column of a summary table). // (specifically when it's a group-by column of a summary table).
disabled: this.origColumn.disableModifyBase, disabled: isDisabled,
}), }),
testId('ref-table-select') testId('ref-table-select')
) )
@ -357,39 +402,58 @@ export class FieldBuilder extends Disposable {
// the dom created by the widgetImpl to get out of sync. // the dom created by the widgetImpl to get out of sync.
return dom('div', return dom('div',
kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) => kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
dom('div', dom('div', widget.buildConfigDom(), cssSeparator())
widget.buildConfigDom(), )
cssSeparator(), );
widget.buildColorConfigDom(this.gristDoc), }
// If there is more than one field for this column (i.e. present in multiple views). public buildColorConfigDom() {
kd.maybe(() => this.origColumn.viewFields().all().length > 1, () => // NOTE: adding a grainjsDom .maybe here causes the disposable order of the widgetImpl and
dom('div.fieldbuilder_settings', // the dom created by the widgetImpl to get out of sync.
kf.row( return dom('div',
kd.toggleClass('fieldbuilder_settings_header', true), kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
kf.label( dom('div', widget.buildColorConfigDom(this.gristDoc))
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(), * Builds the FieldBuilder Options Config DOM. Calls the buildConfigDom function of its widgetImpl.
{ */
useSeparate: () => this.fieldSettingsUseSeparate(), public buildSettingOptions() {
saveAsCommon: () => this.fieldSettingsSaveAsCommon(), // NOTE: adding a grainjsDom .maybe here causes the disposable order of the widgetImpl and
revertToCommon: () => this.fieldSettingsRevertToCommon(), // the dom created by the widgetImpl to get out of sync.
}, return dom('div',
)), kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
), dom('div',
'Field in ', // If there is more than one field for this column (i.e. present in multiple views).
kd.text(() => this.origColumn.viewFields().all().length), kd.maybe(() => this.origColumn.viewFields().all().length > 1, () =>
' views' 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'
)
) )
)
)
) )
)
); );
} }

View File

@ -3,12 +3,12 @@ import { findLinks } from 'app/client/lib/textUtils';
import { DataRowModel } from 'app/client/models/DataRowModel'; import { DataRowModel } from 'app/client/models/DataRowModel';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec'; import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
import { cssRow } from 'app/client/ui/RightPanelStyles'; 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 { colors, testId } from 'app/client/ui2018/cssVars';
import { cssIconBackground, icon } from 'app/client/ui2018/icons'; import { cssIconBackground, icon } from 'app/client/ui2018/icons';
import { gristLink } from 'app/client/ui2018/links'; import { gristLink } from 'app/client/ui2018/links';
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget'; 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. * TextBox - The most basic widget for displaying text information.
@ -20,8 +20,8 @@ export class NTextBox extends NewAbstractWidget {
constructor(field: ViewFieldRec, options: Options = {}) { constructor(field: ViewFieldRec, options: Options = {}) {
super(field, options); super(field, options);
this.alignment = fromKoSave<string>(this.options.prop('alignment')); this.alignment = fromKo(this.options.prop('alignment'));
this.wrapping = fromKo(this.field.wrapping); this.wrapping = fromKo(this.field.wrap);
this.autoDispose(this.wrapping.addListener(() => { this.autoDispose(this.wrapping.addListener(() => {
this.field.viewSection().events.trigger('rowHeightChange'); this.field.viewSection().events.trigger('rowHeightChange');
@ -29,11 +29,28 @@ export class NTextBox extends NewAbstractWidget {
} }
public buildConfigDom(): DomContents { 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 [ return [
cssRow( cssRow(
alignmentSelect(this.alignment), alignmentSelect(
fromKoSave(this.field.config.alignment),
cssButtonSelect.cls('-disabled', alignmentDisabled),
),
dom('div', {style: 'margin-left: 8px;'}, 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') 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)))) 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) { function makeLinks(text: string) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -959,3 +959,21 @@ export function assertIsDefined<T>(name: string, value: T): asserts value is Non
return await fn(); 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;
}

View File

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

View File

@ -224,7 +224,7 @@ export const GristLight: ThemeColors = {
'right-panel-subtab-selected-underline': '#16B378', 'right-panel-subtab-selected-underline': '#16B378',
'right-panel-subtab-hover-fg': '#009058', 'right-panel-subtab-hover-fg': '#009058',
'right-panel-subtab-hover-underline': '#16B378', '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-fg': '#FFFFFF',
'right-panel-toggle-button-enabled-bg': '#262633', 'right-panel-toggle-button-enabled-bg': '#262633',
'right-panel-toggle-button-enabled-hover-fg': '#D9D9D9', 'right-panel-toggle-button-enabled-hover-fg': '#D9D9D9',

1312
test/nbrowser/MultiColumn.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ import * as path from 'path';
import { decodeUrl } from 'app/common/gristUrls'; import { decodeUrl } from 'app/common/gristUrls';
import { FullUser, UserProfile } from 'app/common/LoginSessionAPI'; import { FullUser, UserProfile } from 'app/common/LoginSessionAPI';
import { resetOrg } from 'app/common/resetOrg'; import { resetOrg } from 'app/common/resetOrg';
import { UserAction } from 'app/common/DocActions';
import { TestState } from 'app/common/TestState'; import { TestState } from 'app/common/TestState';
import { Organization as APIOrganization, DocStateComparison, UserAPIImpl, Workspace } from 'app/common/UserAPI'; import { Organization as APIOrganization, DocStateComparison, UserAPIImpl, Workspace } from 'app/common/UserAPI';
import { Organization } from 'app/gen-server/entity/Organization'; 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. * 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. * 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. * 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 toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-field').click(); await driver.find('.test-right-tab-field').click();
await driver.find('.test-fbuilder-type-select').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(); await driver.findContentWait('.test-select-menu .test-select-row', type, 500).click();
if (!options.skipWait) { await waitForServer(); } 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() { export async function applyTypeTransform() {
await driver.findContent('.type_transform_prompt button', /Apply/).click(); await driver.findContent('.type_transform_prompt button', /Apply/).click();
} }
@ -1963,6 +1982,35 @@ export async function setColor(colorInputEl: WebElement, color: string) {
}, colorInputEl, color); }, 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 // the rgbToHex function is from this conversation: https://stackoverflow.com/a/5624139/8728791
export function rgbToHex(color: string) { export function rgbToHex(color: string) {
@ -1989,7 +2037,7 @@ export function hexToRgb(hex: string) {
* Adds new column to the table. * Adds new column to the table.
* @param name Name of the column * @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 scrollIntoView(await driver.find('.active_section .mod-add-column'));
await driver.find('.active_section .mod-add-column').click(); await driver.find('.active_section .mod-add-column').click();
// If we are on a summary table, we could be see a menu helper // 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(name);
await driver.sendKeys(Key.ENTER); await driver.sendKeys(Key.ENTER);
await waitForServer(); await waitForServer();
if (type) {
await setType(exactMatch(type));
}
} }
export async function showColumn(name: string) { export async function showColumn(name: string) {
@ -2019,6 +2070,14 @@ export async function selectColumnRange(col1: string, col2: string) {
await driver.mouseUp(); 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 { export interface WindowDimensions {
width: number; width: number;
height: number; height: number;
@ -2213,7 +2272,16 @@ export async function getDateFormat(): Promise<string> {
*/ */
export async function setDateFormat(format: string|RegExp) { export async function setDateFormat(format: string|RegExp) {
await driver.find('[data-test-id=Widget_dateFormat]').click(); 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(); await waitForServer();
} }
@ -2474,6 +2542,25 @@ export async function setWidgetUrl(url: string) {
await waitForServer(); 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 } // end of namespace gristUtils
stackWrapOwnMethods(gristUtils); stackWrapOwnMethods(gristUtils);