From 8be920dd25a9b06b74aecc798d4a30618b2a8e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Fri, 14 Oct 2022 12:07:19 +0200 Subject: [PATCH] (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 --- app/client/components/BaseView.js | 3 + app/client/components/GridView.js | 10 + app/client/declarations.d.ts | 6 +- app/client/lib/koUtil.js | 14 +- app/client/models/Styles.ts | 12 +- app/client/models/ViewFieldConfig.ts | 312 ++++ app/client/models/entities/ColumnRec.ts | 8 + app/client/models/entities/ViewFieldRec.ts | 82 +- app/client/models/entities/ViewSectionRec.ts | 32 + app/client/ui/FieldConfig.ts | 137 +- app/client/ui/RightPanel.ts | 73 +- app/client/ui/TriggerFormulas.ts | 24 +- app/client/ui2018/ColorSelect.ts | 41 +- app/client/ui2018/buttonSelect.ts | 14 +- app/client/widgets/AttachmentsWidget.ts | 25 +- app/client/widgets/CellStyle.ts | 40 +- app/client/widgets/ChoiceListEditor.ts | 5 +- app/client/widgets/ChoiceListEntry.ts | 107 +- app/client/widgets/ChoiceTextBox.ts | 20 +- app/client/widgets/ConditionalStyle.ts | 15 +- app/client/widgets/CurrencyPicker.ts | 4 +- app/client/widgets/DateTextBox.js | 38 +- app/client/widgets/DateTimeTextBox.js | 43 +- app/client/widgets/FieldBuilder.ts | 184 ++- app/client/widgets/NTextBox.ts | 35 +- app/client/widgets/NumericTextBox.ts | 46 +- app/client/widgets/Reference.ts | 6 +- .../widgets/{UserType.js => UserType.ts} | 66 +- app/client/widgets/UserTypeImpl.js | 59 - app/client/widgets/UserTypeImpl.ts | 71 + app/common/NumberFormat.ts | 17 +- app/common/gutil.ts | 18 + app/common/themes/GristDark.ts | 2 +- app/common/themes/GristLight.ts | 2 +- test/nbrowser/MultiColumn.ts | 1312 +++++++++++++++++ test/nbrowser/gristUtils.ts | 93 +- 36 files changed, 2580 insertions(+), 396 deletions(-) create mode 100644 app/client/models/ViewFieldConfig.ts rename app/client/widgets/{UserType.js => UserType.ts} (79%) delete mode 100644 app/client/widgets/UserTypeImpl.js create mode 100644 app/client/widgets/UserTypeImpl.ts create mode 100644 test/nbrowser/MultiColumn.ts diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index ea5bb0a7..d6869cae 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -183,6 +183,9 @@ function BaseView(gristDoc, viewSectionModel, options) { this.fieldBuilders.at(this.cursor.fieldIndex()) )); + // By default, a view doesn't support selectedColumns, but it can be overridden. + this.selectedColumns = null; + // Observable for whether the data in this view is truncated, i.e. not all rows are included // (this can only be true for on-demand tables). this.isTruncated = ko.observable(false); diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 689875b9..0f3d0225 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -86,6 +86,16 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) { this.cellSelector, this.tableModel.tableData, this.sortedRows, this.viewSection.viewFields); this.colMenuTargets = {}; // Reference from column ref to its menu target dom + this.selectedColumns = this.autoDispose(ko.pureComputed(() => { + const result = this.viewSection.viewFields().all().filter((field, index) => { + // During column removal or restoring (with undo), some columns fields + // might be disposed. + if (field.isDisposed() || field.column().isDisposed()) { return false; } + return this.cellSelector.containsCol(index); + }); + return result; + })); + // Cache of column right offsets, used to determine the col select range this.colRightOffsets = this.autoDispose(ko.computed(() => { let fields = this.viewSection.viewFields(); diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index 64321d55..ed77f2fd 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -12,8 +12,6 @@ declare module "app/client/lib/browserGlobals"; declare module "app/client/lib/dom"; declare module "app/client/lib/koDom"; declare module "app/client/lib/koForm"; -declare module "app/client/widgets/UserType"; -declare module "app/client/widgets/UserTypeImpl"; // tslint:disable:max-classes-per-file @@ -38,7 +36,7 @@ declare module "app/client/components/BaseView" { import {DataRowModel} from 'app/client/models/DataRowModel'; import {LazyArrayModel} from "app/client/models/DataTableModel"; import DataTableModel from "app/client/models/DataTableModel"; - import {ViewSectionRec} from "app/client/models/DocModel"; + import {ViewFieldRec, ViewSectionRec} from "app/client/models/DocModel"; import {FilterInfo} from 'app/client/models/entities/ViewSectionRec'; import {SortedRowSet} from 'app/client/models/rowset'; import {FieldBuilder} from "app/client/widgets/FieldBuilder"; @@ -59,6 +57,7 @@ declare module "app/client/components/BaseView" { public cursor: Cursor; public sortedRows: SortedRowSet; public activeFieldBuilder: ko.Computed; + public selectedColumns: ko.Computed|null; public disableEditing: ko.Computed; public isTruncated: ko.Observable; public tableModel: DataTableModel; @@ -178,6 +177,7 @@ declare module "app/client/models/modelUtil" { prop(propName: string): KoSaveableObservable; } + function objObservable(obs: ko.KoSaveableObservable): SaveableObjObservable; function objObservable(obs: ko.Observable): ObjObservable; function jsonObservable(obs: KoSaveableObservable, modifierFunc?: any, optContext?: any): SaveableObjObservable; diff --git a/app/client/lib/koUtil.js b/app/client/lib/koUtil.js index 8c66c05e..7368edb1 100644 --- a/app/client/lib/koUtil.js +++ b/app/client/lib/koUtil.js @@ -122,9 +122,9 @@ exports.setComputedErrorHandler = setComputedErrorHandler; /** * Returns an observable which mirrors the passed-in argument, but returns a default value if the - * underlying field is falsy. Writes to the returned observable translate directly to writes to the - * underlying one. The default may be a function, evaluated as for computed observables, - * with optContext as the context. + * underlying field is falsy and has non-boolean type. Writes to the returned observable translate + * directly to writes to the underlying one. The default may be a function, evaluated as for computed + * observables, with optContext as the context. */ function observableWithDefault(obs, defaultOrFunc, optContext) { if (typeof defaultOrFunc !== 'function') { @@ -132,7 +132,13 @@ function observableWithDefault(obs, defaultOrFunc, optContext) { defaultOrFunc = function() { return def; }; } return ko.pureComputed({ - read: function() { return obs() || defaultOrFunc.call(this); }, + read: function() { + const value = obs(); + if (typeof value === 'boolean') { + return value; + } + return value || defaultOrFunc.call(this); + }, write: function(val) { obs(val); }, owner: optContext }); diff --git a/app/client/models/Styles.ts b/app/client/models/Styles.ts index 36507424..91382a69 100644 --- a/app/client/models/Styles.ts +++ b/app/client/models/Styles.ts @@ -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 { diff --git a/app/client/models/ViewFieldConfig.ts b/app/client/models/ViewFieldConfig.ts new file mode 100644 index 00000000..5c138f46 --- /dev/null +++ b/app/client/models/ViewFieldConfig.ts @@ -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; + /** If all selected columns have the same widget list. */ + public sameWidgets: ko.Computed; + /** 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; + public widget: ko.Computed; + public alignment: modelUtil.KoSaveableObservable; + public textColor: modelUtil.KoSaveableObservable; + public fillColor: modelUtil.KoSaveableObservable; + public fontBold: modelUtil.KoSaveableObservable; + public fontUnderline: modelUtil.KoSaveableObservable; + public fontItalic: modelUtil.KoSaveableObservable; + public fontStrikethrough: modelUtil.KoSaveableObservable; + private _fields: ko.PureComputed; + 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|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, 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 & { + disabled(prop: string): ko.Computed, + mixed(prop: string): ko.Computed, + empty(prop: string): ko.Computed, +} + +// This is helper that adds disabled computed to an ObjObservable, it follows +// the same pattern as `prop` helper. +function extendObservable( + obs: modelUtil.SaveableObjObservable, + options: { [key: string]: (prop: string) => ko.PureComputed } +): 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; +} diff --git a/app/client/models/entities/ColumnRec.ts b/app/client/models/entities/ColumnRec.ts index 870de7d3..0a372bd6 100644 --- a/app/client/models/entities/ColumnRec.ts +++ b/app/client/models/entities/ColumnRec.ts @@ -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; @@ -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; + // Column behavior as seen by the user. + behavior: ko.Computed; + // The column's display column _displayColModel: ko.Computed; @@ -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( diff --git a/app/client/models/entities/ViewFieldRec.ts b/app/client/models/entities/ViewFieldRec.ts index 025cdafc..68578fd4 100644 --- a/app/client/models/entities/ViewFieldRec.ts +++ b/app/client/models/entities/ViewFieldRec.ts @@ -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; - // Whether lines should wrap in a cell. - wrapping: ko.Computed; disableModify: ko.Computed; disableEditData: ko.Computed; + // Whether lines should wrap in a cell. + wrap: modelUtil.KoSaveableObservable; + widget: modelUtil.KoSaveableObservable; textColor: modelUtil.KoSaveableObservable; fillColor: modelUtil.KoSaveableObservable; fontBold: modelUtil.KoSaveableObservable; fontUnderline: modelUtil.KoSaveableObservable; fontItalic: modelUtil.KoSaveableObservable; fontStrikethrough: modelUtil.KoSaveableObservable; + // Helper computed to change style of a cell without saving it. + style: ko.PureComputed