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