mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Fixing click-away bug for the cell color widget
Summary: After introducing multi columns operation, color picker could save a cell style for a wrong column, if the save operation was triggered by user clicking on one of the cells. Test Plan: Updated Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3668
This commit is contained in:
@@ -1,39 +1,35 @@
|
||||
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";
|
||||
import zip from 'lodash/zip';
|
||||
|
||||
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 */
|
||||
/** Widget options for a field or multiple fields. Doesn't contain style options */
|
||||
public options: CommonOptions;
|
||||
/** Style options for a field or multiple fields */
|
||||
public style: ko.Computed<StyleOptions>;
|
||||
|
||||
// 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[]>;
|
||||
public 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(() => {
|
||||
this.fields = owner.autoDispose(ko.pureComputed(() => {
|
||||
const list = this._field.viewSection().selectedFields();
|
||||
if (!list || !list.length) {
|
||||
return [_field];
|
||||
@@ -46,13 +42,13 @@ export class ViewFieldConfig {
|
||||
}));
|
||||
|
||||
// Just a helper field to see if we have multiple selected columns or not.
|
||||
this.multiselect = owner.autoDispose(ko.pureComputed(() => this._fields().length > 1));
|
||||
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();
|
||||
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.
|
||||
@@ -73,7 +69,7 @@ export class ViewFieldConfig {
|
||||
}
|
||||
// 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());
|
||||
const values = this.fields().map(f => f.widget());
|
||||
if (allSame(values)) {
|
||||
return values[0];
|
||||
} else {
|
||||
@@ -82,7 +78,7 @@ export class ViewFieldConfig {
|
||||
},
|
||||
write: (widget) => {
|
||||
// Go through all the fields, and reset them all.
|
||||
for(const field of this._fields.peek()) {
|
||||
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
|
||||
@@ -102,7 +98,7 @@ export class ViewFieldConfig {
|
||||
// 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();
|
||||
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;
|
||||
@@ -122,18 +118,7 @@ export class ViewFieldConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
return options ?? new Set();
|
||||
}));
|
||||
|
||||
// Prepare our "multi" widgetOptionsJson, that can read and save
|
||||
@@ -147,7 +132,7 @@ export class ViewFieldConfig {
|
||||
// Assemble final json object.
|
||||
const result: any = {};
|
||||
// First get all widgetOption jsons from all columns/fields.
|
||||
const optionList = this._fields().map(f => f.widgetOptionsJson());
|
||||
const optionList = this.fields().map(f => f.widgetOptionsJson());
|
||||
// And fill only those that are common
|
||||
const common = commonOptions();
|
||||
for(const key of common) {
|
||||
@@ -173,9 +158,9 @@ export class ViewFieldConfig {
|
||||
delete value[key];
|
||||
}
|
||||
}
|
||||
// Now update all options, for all fields, be amending the options
|
||||
// Now update all options, for all fields, by amending the options
|
||||
// object from the field/column.
|
||||
for(const item of this._fields.peek()) {
|
||||
for(const item of this.fields.peek()) {
|
||||
const previous = item.widgetOptionsJson.peek();
|
||||
setter(item.widgetOptionsJson, {
|
||||
...previous,
|
||||
@@ -189,10 +174,10 @@ export class ViewFieldConfig {
|
||||
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 mixed value, if not 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)()))),
|
||||
empty: prop => ko.pureComputed(() => allEmpty(this.fields().map(f => f.widgetOptionsJson.prop(prop)()))),
|
||||
}));
|
||||
|
||||
// This is repeated logic for wrap property in viewFieldRec,
|
||||
@@ -202,15 +187,74 @@ export class ViewFieldConfig {
|
||||
() => 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');
|
||||
|
||||
// Style options are a bit different, as they are saved when style picker is disposed.
|
||||
// By the time it happens, fields may have changed (since user might have clicked some other column).
|
||||
// To support this use case we need to compute a snapshot of fields, and use it to save style. Style
|
||||
// picker will be rebuild every time fields change, and it will have access to last selected fields
|
||||
// when it will be disposed.
|
||||
this.style = ko.pureComputed(() => {
|
||||
const fields = this.fields();
|
||||
const multiSelect = fields.length > 1;
|
||||
const savableOptions = modelUtil.savingComputed({
|
||||
read: () => {
|
||||
// For one column, just proxy this to the field.
|
||||
if (!multiSelect) {
|
||||
return this._field.widgetOptionsJson();
|
||||
}
|
||||
// Assemble final json object.
|
||||
const result: any = {};
|
||||
// First get all widgetOption jsons from all columns/fields.
|
||||
const optionList = fields.map(f => f.widgetOptionsJson());
|
||||
// And fill only those that are common
|
||||
for(const key of ['textColor', 'fillColor', 'fontBold',
|
||||
'fontItalic', 'fontUnderline', 'fontStrikethrough']) {
|
||||
// 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 (!multiSelect) {
|
||||
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, by amending the options
|
||||
// object from the field/column.
|
||||
for(const item of fields) {
|
||||
const previous = item.widgetOptionsJson.peek();
|
||||
setter(item.widgetOptionsJson, {
|
||||
...previous,
|
||||
...value,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
// Style picker needs to be able revert to previous value, if user cancels.
|
||||
const state = fields.map(f => f.style.peek());
|
||||
// We need some additional information about each property.
|
||||
const result: StyleOptions = extendObservable(modelUtil.objObservable(savableOptions), {
|
||||
// Property has mixed value, if not all options are the same.
|
||||
mixed: prop => ko.pureComputed(() => !allSame(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(fields.map(f => f.widgetOptionsJson.prop(prop)()))),
|
||||
});
|
||||
result.revert = () => { zip(fields, state).forEach(([f, s]) => f!.style(s!)); };
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// Helper for Choice/ChoiceList columns, that saves widget options and renames values in a document
|
||||
@@ -220,7 +264,7 @@ export class ViewFieldConfig {
|
||||
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());
|
||||
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(
|
||||
@@ -241,26 +285,6 @@ export class ViewFieldConfig {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,18 +308,31 @@ function allEmpty(arr: any[]) {
|
||||
return arr.every(item => ifNotSet(item, null) === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended version of widget options observable that contains information about mixed and empty values.
|
||||
*/
|
||||
type CommonOptions = modelUtil.SaveableObjObservable<any> & {
|
||||
disabled(prop: string): ko.Computed<boolean>,
|
||||
mixed(prop: string): ko.Computed<boolean>,
|
||||
empty(prop: string): ko.Computed<boolean>,
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended version of widget options observable that contains information about mixed and empty styles, and supports
|
||||
* reverting to a previous value.
|
||||
*/
|
||||
type StyleOptions = modelUtil.SaveableObjObservable<any> & {
|
||||
mixed(prop: string): ko.Computed<boolean>,
|
||||
empty(prop: string): ko.Computed<boolean>,
|
||||
revert(): void;
|
||||
}
|
||||
|
||||
// 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}`;
|
||||
|
||||
Reference in New Issue
Block a user