(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

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

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

View File

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

View File

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