(core) Custom Widget column mapping feature.

Summary:
Exposing new API in CustomSectionAPI for column mapping.

The custom widget can call configure method (or use a ready method) with additional parameter "columns".
This parameter is a list of column names that should be mapped by the user.
Mapping configuration is exposed through an additional method in the CustomSectionAPI "mappings". It is also available
through the onRecord(s) event.

This DIFF is connected with PR for grist-widgets repository https://github.com/gristlabs/grist-widget/pull/15

Design document and discussion: https://grist.quip.com/Y2waA8h8Zuzu/Custom-Widget-field-mapping

Test Plan: browser tests

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3241
This commit is contained in:
Jarosław Sadziński
2022-02-08 16:23:14 +01:00
parent 196ab6c473
commit b80e56a4e1
16 changed files with 649 additions and 103 deletions

View File

@@ -18,12 +18,12 @@ import {colors, vars} from 'app/client/ui2018/cssVars';
import {cssDragger} from 'app/client/ui2018/draggableList';
import {icon} from 'app/client/ui2018/icons';
import {linkSelect, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
import {nativeCompare} from 'app/common/gutil';
import {nativeCompare, unwrap} from 'app/common/gutil';
import {BaseFormatter} from 'app/common/ValueFormatter';
import {decodeObject} from 'app/plugin/objtypes';
import {Events as BackboneEvents} from 'backbone';
import {Computed, dom, DomContents, DomElementArg, fromKo, Disposable as GrainJSDisposable,
IDisposable, IOption, ISubscribable, makeTestId, Observable, styled, UseCB} from 'grainjs';
IDisposable, IOption, makeTestId, Observable, styled, UseCB} from 'grainjs';
import * as ko from 'knockout';
import clamp = require('lodash/clamp');
import debounce = require('lodash/debounce');
@@ -1133,11 +1133,3 @@ const cssSpinners = styled('input', `
opacity: 1;
}
`);
// Returns the value of both granjs and knockout observable without creating a dependency.
const unwrap: UseCB = (obs: ISubscribable) => {
if ('_getDepItem' in obs) {
return obs.get();
}
return (obs as ko.Observable).peek();
};

View File

@@ -1,18 +1,19 @@
import * as BaseView from 'app/client/components/BaseView';
import {GristDoc} from 'app/client/components/GristDoc';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {AccessLevel, isSatisfied} from 'app/common/CustomWidget';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions';
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes';
import {CustomSectionAPI, GristDocAPI, GristView, InteractionOptionsRequest,
WidgetAPI} from 'app/plugin/grist-plugin-api';
import {CustomSectionAPI, GristDocAPI, GristView,
InteractionOptionsRequest, WidgetAPI, WidgetColumnMap} from 'app/plugin/grist-plugin-api';
import {MsgType, Rpc} from 'grain-rpc';
import {Computed, dom} from 'grainjs';
import {Computed, Disposable, dom, Observable} from 'grainjs';
import noop = require('lodash/noop');
import debounce = require('lodash/debounce');
import isEqual = require('lodash/isEqual');
import flatMap = require('lodash/flatMap');
/**
* This file contains a WidgetFrame and all its components.
@@ -323,15 +324,18 @@ export class GristViewImpl implements GristView {
constructor(private _baseView: BaseView) {}
public async fetchSelectedTable(): Promise<any> {
const fields: ViewFieldRec[] = this._baseView.viewSection.viewFields().all();
// If widget has a custom columns mapping, we will ignore hidden columns section.
// Hidden/Visible columns will eventually reflect what is available, but this operation
// is not instant - and widget can receive rows with fields that are not in the mapping.
const columns: ColumnRec[] = this._visibleColumns();
const rowIds: number[] = this._baseView.sortedRows.getKoArray().peek() as number[];
const data: BulkColValues = {};
for (const field of fields) {
for (const column of columns) {
// Use the colId of the displayCol, which may be different in case of Reference columns.
const colId: string = field.displayColModel.peek().colId.peek();
const colId: string = column.displayColModel.peek().colId.peek();
const getter = this._baseView.tableModel.tableData.getRowPropFunc(colId)!;
const typeInfo = extractInfoFromColType(field.column.peek().type.peek());
data[field.column().colId()] = rowIds.map(r => reencodeAsAny(getter(r)!, typeInfo));
const typeInfo = extractInfoFromColType(column.type.peek());
data[column.colId.peek()] = rowIds.map(r => reencodeAsAny(getter(r)!, typeInfo));
}
data.id = rowIds;
return data;
@@ -343,18 +347,30 @@ export class GristViewImpl implements GristView {
// more useful. but the data engine needs to know what information
// the custom view depends on, so we shouldn't volunteer any untracked
// information here.
const fields: ViewFieldRec[] = this._baseView.viewSection.viewFields().all();
const columns: ColumnRec[] = this._visibleColumns();
const data: RowRecord = {id: rowId};
for (const field of fields) {
const colId: string = field.displayColModel.peek().colId.peek();
const typeInfo = extractInfoFromColType(field.column.peek().type.peek());
data[field.column().colId()] = reencodeAsAny(
for (const column of columns) {
const colId: string = column.displayColModel.peek().colId.peek();
const typeInfo = extractInfoFromColType(column.type.peek());
data[column.colId.peek()] = reencodeAsAny(
this._baseView.tableModel.tableData.getValue(rowId, colId)!,
typeInfo
);
}
return data;
}
private _visibleColumns() {
const columns: ColumnRec[] = this._baseView.viewSection.columns.peek();
const hiddenCols = this._baseView.viewSection.hiddenColumns.peek().map(c => c.id.peek());
const mappings = this._baseView.viewSection.mappedColumns.peek();
const mappedColumns = new Set(flatMap(Object.values(mappings || {})));
const notHidden = (col: ColumnRec) => !hiddenCols.includes(col.id.peek());
const mapped = (col: ColumnRec) => mappings && mappedColumns.has(col.colId.peek());
// If columns are mapped, return only those that are mapped.
// Otherwise return all not hidden columns;
return mappings ? columns.filter(mapped) : columns.filter(notHidden);
}
}
/**
@@ -369,7 +385,6 @@ export class WidgetAPIImpl implements WidgetAPI {
* between widgets by design.
*/
public async setOptions(options: object): Promise<void> {
console.debug(`set options`, options);
if (options === null || options === undefined || typeof options !== 'object') {
throw new Error('options must be a valid JSON object');
}
@@ -377,24 +392,20 @@ export class WidgetAPIImpl implements WidgetAPI {
}
public async getOptions(): Promise<Record<string, unknown> | null> {
console.debug(`getOptions`);
return this._section.activeCustomOptions.peek() ?? null;
}
public async clearOptions(): Promise<void> {
console.debug(`clearOptions`);
this._section.activeCustomOptions(null);
}
public async setOption(key: string, value: any): Promise<void> {
console.debug(`setOption(${key}, ${value})`);
const options = {...this._section.activeCustomOptions.peek()};
options[key] = value;
this._section.activeCustomOptions(options);
}
public getOption(key: string): Promise<unknown> {
console.debug(`getOption(${key})`);
const options = this._section.activeCustomOptions.peek();
return options?.[key];
}
@@ -474,14 +485,17 @@ export class ConfigNotifier extends BaseEventSource {
return options;
});
this._debounced = debounce(() => this._update(), 0);
this.autoDispose(
this._currentConfig.addListener((cur, prev) => {
if (isEqual(prev, cur)) {
return;
}
this._debounced();
})
);
const subscribe = (obs: Observable<any>) => {
this.autoDispose(
obs.addListener((cur, prev) => {
if (isEqual(prev, cur)) {
return;
}
this._debounced();
})
);
};
subscribe(this._currentConfig);
}
protected _ready() {
@@ -495,7 +509,9 @@ export class ConfigNotifier extends BaseEventSource {
}
this._notify({
options: this._currentConfig.get(),
settings: {accessLevel: this._accessLevel},
settings: {
accessLevel: this._accessLevel,
},
});
}
}
@@ -506,13 +522,20 @@ export class ConfigNotifier extends BaseEventSource {
* This Notifier sends an initial event when subscribed
*/
export class TableNotifier extends BaseEventSource {
private _debounced: () => void; // debounced call to let the view know linked data changed.
private _debounced: () => void;
private _updateMapping = true;
constructor(private _baseView: BaseView) {
super();
this._debounced = debounce(() => this._update(), 0);
this.autoDispose(_baseView.viewSection.viewFields().subscribe(this._debounced));
this.listenTo(_baseView.sortedRows, 'rowNotify', this._debounced);
this.autoDispose(_baseView.sortedRows.getKoArray().subscribe(this._debounced));
this.autoDispose(_baseView.viewSection.viewFields().subscribe(this._debounced.bind(this)));
this.listenTo(_baseView.sortedRows, 'rowNotify', this._debounced.bind(this));
this.autoDispose(_baseView.sortedRows.getKoArray().subscribe(this._debounced.bind(this)));
this.autoDispose(_baseView.viewSection.mappedColumns
.subscribe(() => {
this._updateMapping = true;
this._debounced();
})
);
}
protected _ready() {
@@ -528,17 +551,26 @@ export class TableNotifier extends BaseEventSource {
tableId: this._baseView.viewSection.table().tableId(),
rowId: this._baseView.cursor.getCursorPos().rowId || undefined,
dataChange: true,
mappingsChange: this._updateMapping
};
this._updateMapping = false;
this._notify(state);
}
}
export class CustomSectionAPIImpl implements CustomSectionAPI {
export class CustomSectionAPIImpl extends Disposable implements CustomSectionAPI {
constructor(
private _section: ViewSectionRec,
private _currentAccess: AccessLevel,
private _promptCallback: (access: AccessLevel) => void
) {}
) {
super();
}
public async mappings(): Promise<WidgetColumnMap|null> {
return this._section.mappedColumns.peek();
}
/**
* Method called as part of ready message. Allows widget to request for particular features or inform about
* capabilities.
@@ -550,5 +582,10 @@ export class CustomSectionAPIImpl implements CustomSectionAPI {
if (settings.requiredAccess && settings.requiredAccess !== this._currentAccess) {
this._promptCallback(settings.requiredAccess as AccessLevel);
}
if (settings.columns !== undefined) {
this._section.columnsToMap(settings.columns);
} else {
this._section.columnsToMap(null);
}
}
}

View File

@@ -0,0 +1,37 @@
import * as UserType from 'app/client/widgets/UserType';
import {ColumnToMap} from 'app/plugin/CustomSectionAPI';
/**
* Helper that wraps custom widget's column definition and expands all the defaults.
*/
export class ColumnToMapImpl implements Required<ColumnToMap> {
// Name of the column Custom Widget expects.
public name: string;
// Label to show instead of the name.
public title: string;
// If column is optional (used only on the UI).
public optional: boolean;
// Type of the column that widget expects.
public type: string;
// Description of the type (used to show a placeholder).
public typeDesc: string;
// Allow multiple column assignment (like Series in Charts).
public allowMultiple: boolean;
constructor(def: string|ColumnToMap) {
this.name = typeof def === 'string' ? def : def.name;
this.title = typeof def === 'string' ? def : (def.title ?? def.name);
this.optional = typeof def === 'string' ? false : (def.optional ?? false);
this.type = typeof def === 'string' ? 'Any' : (def.type ?? 'Any');
this.typeDesc = String(UserType.typeDefs[this.type]?.label ?? "any").toLowerCase();
this.allowMultiple = typeof def === 'string' ? false : (def.allowMultiple ?? false);
}
/**
* Does the column type matches this definition.
*/
public canByMapped(pureType: string) {
return pureType === this.type
|| pureType === "Any"
|| this.type === "Any";
}
}

View File

@@ -20,6 +20,8 @@ import {getWidgetTypes} from 'app/client/ui/widgetTypes';
import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget';
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 {Computed} from 'grainjs';
import * as ko from 'knockout';
import defaults = require('lodash/defaults');
@@ -141,9 +143,16 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
// We won't freeze all the columns on a grid, it will leave at least 1 column unfrozen.
numFrozen: ko.Computed<number>;
activeCustomOptions: modelUtil.CustomComputed<any>;
// Temporary variable holding flag that describes if the widget supports custom options (set by api).
// Temporary fields used to communicate with the Custom Widget. There are set through the Widget API.
// Temporary variable holding columns mapping requested by the widget (set by API).
columnsToMap: ko.Observable<ColumnsToMap|null>;
// Temporary variable holding columns mapped by the user;
mappedColumns: ko.Computed<WidgetColumnMap|null>;
// Temporary variable holding flag that describes if the widget supports custom options (set by API).
hasCustomOptions: ko.Observable<boolean>;
// Temporary variable holding widget desired access (changed either from manifest or via api).
// Temporary variable holding widget desired access (changed either from manifest or via API).
desiredAccessLevel: ko.Observable<AccessLevel|null>;
// Save all filters of fields/columns in the section.
@@ -159,6 +168,9 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
saveCustomDef(): Promise<void>;
}
export type WidgetMappedColumn = number|number[]|null;
export type WidgetColumnMapping = Record<string, WidgetMappedColumn>
export interface CustomViewSectionDef {
/**
* The mode.
@@ -176,6 +188,10 @@ export interface CustomViewSectionDef {
* Custom widget options.
*/
widgetOptions: modelUtil.KoSaveableObservable<Record<string, any>|null>;
/**
* Custom widget interaction options.
*/
columnsMapping: modelUtil.KoSaveableObservable<WidgetColumnMapping|null>;
/**
* Access granted to url.
*/
@@ -233,6 +249,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
url: customDefObj.prop('url'),
widgetDef: customDefObj.prop('widgetDef'),
widgetOptions: customDefObj.prop('widgetOptions'),
columnsMapping: customDefObj.prop('columnsMapping'),
access: customDefObj.prop('access'),
pluginId: customDefObj.prop('pluginId'),
sectionId: customDefObj.prop('sectionId')
@@ -497,4 +514,51 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this.hasCustomOptions = ko.observable(false);
this.desiredAccessLevel = ko.observable(null);
this.columnsToMap = ko.observable(null);
// Calculate mapped columns for Custom Widget.
this.mappedColumns = ko.pureComputed(() => {
// First check if widget has requested a custom column mapping and
// if we have a saved configuration.
const request = this.columnsToMap();
const mapping = this.customDef.columnsMapping();
if (!request) {
return null;
}
// Convert simple column expressions (widget can just specify a name of a column) to a rich column definition.
const columnsToMap = request.map(r => new ColumnToMapImpl(r));
if (!mapping) {
// If we don't have mappings, return an empty object.
return columnsToMap.reduce((o: WidgetColumnMap, c) => {
o[c.name] = c.allowMultiple ? [] : null;
return o;
}, {});
}
const result: WidgetColumnMap = {};
// Prepare map of existing column, will need this for translating colRefs to colIds.
const colMap = new Map(this.columns().map(f => [f.id.peek(), f]));
for(const widgetCol of columnsToMap) {
// Start with marking this column as not mapped.
result[widgetCol.name] = widgetCol.allowMultiple ? [] : null;
const mappedCol = mapping[widgetCol.name];
if (!mappedCol) {
continue;
}
if (widgetCol.allowMultiple) {
// We expect a list of colRefs be mapped;
if (!Array.isArray(mappedCol)) { continue; }
result[widgetCol.name] = mappedCol
// Remove all colRefs saved but deleted
.filter(cId => colMap.has(cId))
// And those with wrong type.
.filter(cId => widgetCol.canByMapped(colMap.get(cId)!.pureType()))
.map(cId => colMap.get(cId)!.colId());
} else {
// Widget expects a single value and existing column
if (Array.isArray(mappedCol) || !colMap.has(mappedCol)) { continue; }
const selectedColumn = colMap.get(mappedCol)!;
result[widgetCol.name] = widgetCol.canByMapped(selectedColumn.pureType()) ? selectedColumn.colId() : null;
}
}
return result;
});
}

View File

@@ -1,18 +1,23 @@
import {allCommands} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import * as kf from 'app/client/lib/koForm';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors';
import {cssLabel, cssRow, cssTextInput} from 'app/client/ui/RightPanel';
import {cssLabel, cssRow, cssSeparator, cssSubLabel, cssTextInput} from 'app/client/ui/RightPanel';
import {cssDragRow, cssFieldEntry, cssFieldLabel} from 'app/client/ui/VisibleFieldsConfig';
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
import {colors} from 'app/client/ui2018/cssVars';
import {cssDragger} from 'app/client/ui2018/draggableList';
import {IconName} from 'app/client/ui2018/IconList';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {IOptionFull, select} from 'app/client/ui2018/menus';
import {IOptionFull, menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
import {AccessLevel, ICustomWidget, isSatisfied} from 'app/common/CustomWidget';
import {GristLoadConfig} from 'app/common/gristUrls';
import {nativeCompare} from 'app/common/gutil';
import {bundleChanges, Computed, Disposable, dom, fromKo, makeTestId, MultiHolder, Observable, styled} from 'grainjs';
import {nativeCompare, unwrap} from 'app/common/gutil';
import {bundleChanges, Computed, Disposable, dom, fromKo, makeTestId,
MultiHolder, Observable, styled, UseCBOwner} from 'grainjs';
// Custom URL widget id - used as mock id for selectbox.
const CUSTOM_ID = 'custom';
@@ -27,9 +32,181 @@ const testId = makeTestId('test-config-widget-');
* so prompt won't be shown.
*
* When gristConfig.enableWidgetRepository is set to false, it will only
* allow to specify Custom URL.
* allow to specify the custom URL.
*/
class ColumnPicker extends Disposable {
constructor(
private _value: Observable<number|number[]|null>,
private _column: ColumnToMapImpl,
private _section: ViewSectionRec){
super();
}
public buildDom() {
// Rewrite value to ignore old configuration when allowMultiple is switched.
const properValue = Computed.create(this, use => {
const value = use(this._value);
return Array.isArray(value) ? null : value;
});
properValue.onWrite(value => this._value.set(value));
const options = Computed.create(this, use => {
return use(this._section.columns)
.filter(col => this._column.canByMapped(use(col.pureType)))
.map((col) => ({value: col.getRowId(), label: use(col.label), icon: 'FieldColumn' as IconName}));
});
return [
cssLabel(
this._column.title,
this._column.optional ? cssSubLabel(" (optional)") : null,
testId('label-for-' + this._column.name),
),
cssRow(
select(
properValue,
options,
{
defaultLabel: this._column.typeDesc != "any" ? `Pick a ${this._column.typeDesc} column` : 'Pick a column'
}
),
testId('mapping-for-' + this._column.name),
),
];
}
}
class ColumnListPicker extends Disposable {
constructor(
private _value: Observable<number|number[]|null>,
private _column: ColumnToMapImpl,
private _section: ViewSectionRec) {
super();
}
public buildDom() {
return dom.domComputed((use) => {
return [
cssLabel(this._column.title,
cssLabel.cls("-required", !this._column.optional),
testId('label-for-' + this._column.name),
),
this._buildDraggableList(use),
this._buildAddColumn()
];
});
}
private _buildAddColumn() {
return [
cssRow(
cssAddMapping(
cssAddIcon('Plus'), 'Add ' + this._column.title,
menu(() => {
const otherColumns = this._getNotMappedColumns();
const typedColumns = otherColumns.filter(this._typeFilter());
const wrongTypeCount = otherColumns.length - typedColumns.length;
return [
...typedColumns
.map((col) => menuItem(
() => this._addColumn(col),
col.label.peek(),
)),
wrongTypeCount > 0 ? menuText(
`${wrongTypeCount} non-${this._column.type.toLowerCase()} column${wrongTypeCount > 1 ? 's are' : ' is'} not shown`,
testId('map-message-' + this._column.name)
) : null
];
}),
testId('add-column-for-' + this._column.name),
)
),
];
}
// Helper method for filtering columns that can be picked by the widget.
private _typeFilter = (use = unwrap) => (col: ColumnRec) => this._column.canByMapped(use(col.pureType));
private _buildDraggableList(use: UseCBOwner) {
return dom.update(kf.draggableList(
this._readItems(use),
this._renderItem.bind(this, use),
{
itemClass: cssDragRow.className,
reorder: this._reorder.bind(this),
receive: this._addColumn.bind(this),
drag_indicator: cssDragger,
}
), testId('map-list-for-' + this._column.name));
}
private _getNotMappedColumns(): ColumnRec[] {
// Get all columns.
const all = this._section.columns.peek();
const mapped = this._list();
return all.filter(col => !mapped.includes(col.id.peek()));
}
private _readItems(use: UseCBOwner): ColumnRec[] {
let selectedRefs = (use(this._value) || []) as number[];
// Ignore if configuration was changed from what it was saved.
if (!Array.isArray(selectedRefs)) {
selectedRefs = [];
}
// Filter columns by type - when column type has changed since mapping.
const columns = use(this._section.columns).filter(this._typeFilter(use));
const columnMap = new Map(columns.map(c => [c.id.peek(), c]));
// Remove any columns that are no longer there.
const selectedFields = selectedRefs.map(s => columnMap.get(s)!).filter(c => Boolean(c));
return selectedFields;
}
private _renderItem(use: UseCBOwner, field: ColumnRec): any {
return cssFieldEntry(
cssFieldLabel(
dom.text(field.label),
testId('ref-select-label'),
),
cssRemoveIcon(
'Remove',
dom.on('click', () => this._remove(field)),
testId('ref-select-remove'),
),
);
}
// Helper method that for accessing mapped columns. Can be used to set and retrieve the value.
private _list(value: number[]): void
private _list(): number[]
private _list(value?: number[]) {
if (value) {
this._value.set(value);
} else {
let current = (this._value.get() || []) as number[];
// Ignore if the saved value is not a number.
if (!Array.isArray(current)) {
current = [];
}
return current;
}
}
private _reorder(column: ColumnRec, nextColumn: ColumnRec|null): any {
const id = column.id.peek();
const nextId = nextColumn?.id.peek();
const currentList = this._list();
const indexOfId = currentList.indexOf(id);
// Remove element from the list.
currentList.splice(indexOfId, 1);
const indexOfNext = nextId ? currentList.indexOf(nextId) : currentList.length;
// Insert before next element or at the end.
currentList.splice(indexOfNext, 0, id);
this._list(currentList);
}
private _remove(column: ColumnRec): any {
const current = this._list();
this._value.set(current.filter(c => c != column.id.peek()));
}
private _addColumn(col: ColumnRec): any {
const current = this._list();
current.push(col.id.peek());
this._value.set(current);
}
}
export class CustomSectionConfig extends Disposable {
// Holds all available widget definitions.
private _widgets: Observable<ICustomWidget[]>;
@@ -47,7 +224,7 @@ export class CustomSectionConfig extends Disposable {
// Does widget has custom configuration.
private _hasConfiguration: Computed<boolean>;
constructor(_section: ViewSectionRec, _gristDoc: GristDoc) {
constructor(private _section: ViewSectionRec, _gristDoc: GristDoc) {
super();
const api = _gristDoc.app.topAppModel.api;
@@ -82,24 +259,12 @@ export class CustomSectionConfig extends Disposable {
.catch(reportError);
}
// Create temporary variable that will hold blank Custom Url state. When url is blank and widgetDef is not stored
// we can either show "Select Custom Widget" or a Custom Url with a blank url.
// To distinguish those states, we will mark Custom Url state at start (by checking that url is not blank and
// widgetDef is not set). And then switch it during selectbox manipulation.
const wantsToBeCustom = Observable.create(
this,
Boolean(_section.customDef.url.peek() && !_section.customDef.widgetDef.peek())
);
// Selected value from the dropdown (contains widgetId or "custom" string for Custom URL)
this._selectedId = Computed.create(this, use => {
if (use(_section.customDef.widgetDef)) {
return _section.customDef.widgetDef.peek()!.widgetId;
}
if (use(_section.customDef.url) || use(wantsToBeCustom)) {
return CUSTOM_ID;
}
return null;
return CUSTOM_ID;
});
this._selectedId.onWrite(async value => {
if (value === CUSTOM_ID) {
@@ -109,14 +274,15 @@ export class CustomSectionConfig extends Disposable {
_section.customDef.url(null);
// Clear widget definition.
_section.customDef.widgetDef(null);
// Set intermediate state
wantsToBeCustom.set(true);
// Reset access level to none.
_section.customDef.access(AccessLevel.none);
// Clear all saved options.
_section.customDef.widgetOptions(null);
// Reset custom configuration flag.
_section.hasCustomOptions(false);
// Clear column mappings.
_section.customDef.columnsMapping(null);
_section.columnsToMap(null);
this._desiredAccess.set(AccessLevel.none);
});
await _section.saveCustomDef();
@@ -144,8 +310,9 @@ export class CustomSectionConfig extends Disposable {
_section.customDef.widgetOptions(null);
// Clear has custom configuration.
_section.hasCustomOptions(false);
// Clear intermediate state.
wantsToBeCustom.set(false);
// Clear column mappings.
_section.customDef.columnsMapping(null);
_section.columnsToMap(null);
});
await _section.saveCustomDef();
}
@@ -168,7 +335,6 @@ export class CustomSectionConfig extends Disposable {
this._desiredAccess = fromKo(_section.desiredAccessLevel);
// Clear intermediate state when section changes.
this.autoDispose(_section.id.subscribe(() => wantsToBeCustom.set(false)));
this.autoDispose(_section.id.subscribe(() => this._reject()));
this._hasConfiguration = Computed.create(this, use => use(_section.hasCustomOptions));
@@ -198,7 +364,7 @@ export class CustomSectionConfig extends Disposable {
switch(level) {
case AccessLevel.none: return cssConfirmLine("Widget does not require any permissions.");
case AccessLevel.read_table: return cssConfirmLine("Widget needs to ", dom("b", "read"), " the current table.");
case AccessLevel.full: return cssConfirmLine("Widget needs a ", dom("b", "full access"), " to this document.");
case AccessLevel.full: return cssConfirmLine("Widget needs ", dom("b", "full access"), " to this document.");
default: throw new Error(`Unsupported ${level} access level`);
}
}
@@ -279,6 +445,31 @@ export class CustomSectionConfig extends Disposable {
'Learn more about custom widgets'
)
),
dom.maybeOwned(use => use(this._section.columnsToMap), (owner, columns) => {
const createObs = (column: ColumnToMapImpl) => {
const obs = Computed.create(owner, use => {
const savedDefinition = use(this._section.customDef.columnsMapping) || {};
return savedDefinition[column.name];
});
obs.onWrite(async (value) => {
const savedDefinition = this._section.customDef.columnsMapping.peek() || {};
savedDefinition[column.name] = value;
await this._section.customDef.columnsMapping.setAndSave(savedDefinition);
});
return obs;
};
// Create observables for all columns to pick.
const mappings = columns.map(c => new ColumnToMapImpl(c)).map((column) => ({
value: createObs(column),
column
}));
return [
cssSeparator(),
...mappings.map(m => m.column.allowMultiple
? dom.create(ColumnListPicker, m.value, m.column, this._section)
: dom.create(ColumnPicker, m.value, m.column, this._section))
];
})
);
}
@@ -298,6 +489,7 @@ export class CustomSectionConfig extends Disposable {
}
}
const cssWarningWrapper = styled('div', `
padding-left: 8px;
padding-top: 6px;
@@ -327,3 +519,32 @@ const cssMenu = styled('div', `
border-bottom: 1px solid ${colors.mediumGrey};
}
`);
const cssAddIcon = styled(icon, `
margin-right: 4px;
`);
const cssRemoveIcon = styled(icon, `
display: none;
cursor: pointer;
flex: none;
margin-left: 8px;
.${cssFieldEntry.className}:hover & {
display: block;
}
`);
const cssAddMapping = styled('div', `
display: flex;
cursor: pointer;
color: ${colors.lightGreen};
--icon-color: ${colors.lightGreen};
&:not(:first-child) {
margin-top: 8px;
}
&:hover, &:focus, &:active {
color: ${colors.darkGreen};
--icon-color: ${colors.darkGreen};
}
`);

View File

@@ -26,6 +26,7 @@ import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {GridOptions} from 'app/client/ui/GridOptions';
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkFromId, linkId, selectBy} from 'app/client/ui/selectBy';
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
import {IWidgetType, widgetTypes} from 'app/client/ui/widgetTypes';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
@@ -40,7 +41,6 @@ import {bundleChanges, Computed, Disposable, dom, domComputed, DomContents,
DomElementArg, DomElementMethod, IDomComponent} from 'grainjs';
import {MultiHolder, Observable, styled, subscribe} from 'grainjs';
import * as ko from 'knockout';
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
// Represents a top tab of the right side-pane.
const TopTab = StringUnion("pageWidget", "field");
@@ -292,6 +292,11 @@ export class RightPanel extends Disposable {
// TODO: This uses private methods from ViewConfigTab. These methods are likely to get
// refactored, but if not, should be made public.
const viewConfigTab = this._createViewConfigTab(owner);
const hasCustomMapping = Computed.create(owner, use => {
const isCustom = use(this._pageWidgetType) === 'custom';
const hasColumnMapping = use(activeSection.columnsToMap);
return Boolean(isCustom && hasColumnMapping);
});
return dom.maybe(viewConfigTab, (vct) => [
this._disableIfReadonly(),
cssLabel('WIDGET TITLE',
@@ -341,7 +346,7 @@ export class RightPanel extends Disposable {
];
}),
dom.maybe((use) => use(this._pageWidgetType) !== 'chart', () => [
dom.maybe((use) => !use(hasCustomMapping) && use(this._pageWidgetType) !== 'chart', () => [
cssSeparator(),
dom.create(VisibleFieldsConfig, this._gristDoc, activeSection),
]),
@@ -544,6 +549,13 @@ export const cssLabel = styled('div', `
font-size: ${vars.xsmallFontSize};
`);
// Additional text in label (greyed out)
export const cssSubLabel = styled('span', `
text-transform: none;
font-size: ${vars.xsmallFontSize};
color: ${colors.slate};
`);
export const cssRow = styled('div', `
display: flex;
margin: 8px 16px;

View File

@@ -86,7 +86,7 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
// [+] Toggle filter bar
makeFilterBarToggle(viewSection.activeFilterBar),
// Widget options
dom.maybe(use => use(viewSection.customDef.mode) === 'url', () =>
dom.maybe(use => use(viewSection.parentKey) === 'custom', () =>
makeCustomOptions(viewSection)
),
// [Save] [Revert] buttons

View File

@@ -270,12 +270,22 @@ export class VisibleFieldsConfig extends Disposable {
}
public async removeField(field: IField) {
const id = field.id.peek();
const existing = this._section.viewFields.peek().peek()
.find((f) => f.column.peek().getRowId() === field.origCol.peek().id.peek());
if (!existing) {
return;
}
const id = existing.id.peek();
const action = ['RemoveRecord', id];
await this._gristDoc.docModel.viewFields.sendTableAction(action);
}
public async addField(column: IField, nextField: ViewFieldRec|null = null) {
const exists = this._section.viewFields.peek().peek()
.findIndex((f) => f.column.peek().getRowId() === column.id.peek());
if (exists !== -1) {
return;
}
const parentPos = getFieldNewPosition(this._section.viewFields.peek(), column, nextField);
const colInfo = {
parentId: this._section.id.peek(),
@@ -430,7 +440,7 @@ function unselectDeletedFields(selection: Set<number>, event: {deleted: IField[]
}
}
const cssDragRow = styled('div', `
export const cssDragRow = styled('div', `
display: flex !important;
align-items: center;
margin: 0 16px 0px 0px;