(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
pull/131/head
Jarosław Sadziński 2 years ago
parent 196ab6c473
commit b80e56a4e1

@ -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();
};

@ -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);
}
}
}

@ -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";
}
}

@ -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;
});
}

@ -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};
}
`);

@ -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;

@ -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

@ -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;

@ -1,14 +1,10 @@
import { CellValue, CellVersions } from 'app/common/DocActions';
import { GristObjCode } from 'app/plugin/GristData';
import { GristObjCode, GristType } from 'app/plugin/GristData';
import isString = require('lodash/isString');
import { removePrefix } from "./gutil";
// tslint:disable:object-literal-key-quotes
export type GristType = 'Any' | 'Attachments' | 'Blob' | 'Bool' | 'Choice' | 'ChoiceList' |
'Date' | 'DateTime' |
'Id' | 'Int' | 'ManualSortPos' | 'Numeric' | 'PositionNumber' | 'Ref' | 'RefList' | 'Text';
export type GristTypeInfo =
{type: 'DateTime', timezone: string} |
{type: 'Ref', tableId: string} |

@ -1,5 +1,5 @@
import {delay} from 'app/common/delay';
import {BindableValue, DomElementMethod, Listener, Observable, subscribeElem} from 'grainjs';
import {BindableValue, DomElementMethod, ISubscribable, Listener, Observable, subscribeElem, UseCB} from 'grainjs';
import {Observable as KoObservable} from 'knockout';
import constant = require('lodash/constant');
import identity = require('lodash/identity');
@ -905,3 +905,13 @@ export function isAffirmative(parameter: any): boolean {
export function isObject<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
/**
* Returns the value of both grainjs and knockout observable without creating a dependency.
*/
export const unwrap: UseCB = (obs: ISubscribable) => {
if ('_getDepItem' in obs) {
return obs.get();
}
return (obs as ko.Observable).peek();
};

@ -4,22 +4,41 @@
import * as t from "ts-interface-checker";
// tslint:disable:object-literal-key-quotes
export const RequestedInteractionOptions = t.iface([], {
export const ColumnToMap = t.iface([], {
"name": "string",
"title": t.opt(t.union("string", "null")),
"type": t.opt("string"),
"optional": t.opt("boolean"),
"allowMultiple": t.opt("boolean"),
});
export const ColumnsToMap = t.array(t.union("string", "ColumnToMap"));
export const InteractionOptionsRequest = t.iface([], {
"requiredAccess": t.opt("string"),
"hasCustomOptions": t.opt("boolean"),
"columns": t.opt("ColumnsToMap"),
});
export const InteractionOptions = t.iface([], {
"accessLevel": "string",
});
export const WidgetColumnMap = t.iface([], {
[t.indexKey]: t.union("string", t.array("string"), "null"),
});
export const CustomSectionAPI = t.iface([], {
"configure": t.func("void", t.param("customOptions", "RequestedInteractionOptions")),
"configure": t.func("void", t.param("customOptions", "InteractionOptionsRequest")),
"mappings": t.func(t.union("WidgetColumnMap", "null")),
});
const exportedTypeSuite: t.ITypeSuite = {
RequestedInteractionOptions,
ColumnToMap,
ColumnsToMap,
InteractionOptionsRequest,
InteractionOptions,
WidgetColumnMap,
CustomSectionAPI,
};
export default exportedTypeSuite;

@ -2,6 +2,31 @@
* API definitions for CustomSection plugins.
*/
export interface ColumnToMap {
/**
* Column name that Widget expects. Must be a valid JSON property name.
*/
name: string;
/**
* Title or short description of a column (used as a label in section mapping).
*/
title?: string|null,
/**
* Column type, by default ANY.
*/
type?: string, // GristType, TODO: ts-interface-checker doesn't know how to parse this
/**
* Mark column as optional all columns are required by default.
*/
optional?: boolean
/**
* Allow multiple column assignment, the result will be list of mapped table column names.
*/
allowMultiple?: boolean,
}
export type ColumnsToMap = (string|ColumnToMap)[];
/**
* Initial message sent by the CustomWidget with initial requirements.
*/
@ -16,18 +41,37 @@ export interface InteractionOptionsRequest {
* can use to show custom options screen.
*/
hasCustomOptions?: boolean,
/**
* Tells Grist what columns Custom Widget expects and allows user to map between existing column names
* and those requested by Custom Widget.
*/
columns?: ColumnsToMap,
}
/**
* Widget configuration set and approved by Grist, sent as part of ready message.
*/
export interface InteractionOptions {
export interface InteractionOptions{
/**
* Granted access level.
*/
accessLevel: string
accessLevel: string,
}
/**
* Current columns mapping between viewFields in section and Custom widget.
*/
export interface WidgetColumnMap {
[key: string]: string|string[]|null
}
export interface CustomSectionAPI {
/**
* Initial request from a Custom Widget that wants to declare its requirements.
*/
configure(customOptions: InteractionOptionsRequest): Promise<void>;
/**
* Returns current widget configuration (if requested through configuration method).
*/
mappings(): Promise<WidgetColumnMap|null>;
}

@ -27,9 +27,12 @@ export const RowRecord = t.iface([], {
[t.indexKey]: "CellValue",
});
export const GristType = t.union(t.lit('Any'), t.lit('Attachments'), t.lit('Blob'), t.lit('Bool'), t.lit('Choice'), t.lit('ChoiceList'), t.lit('Date'), t.lit('DateTime'), t.lit('Id'), t.lit('Int'), t.lit('ManualSortPos'), t.lit('Numeric'), t.lit('PositionNumber'), t.lit('Ref'), t.lit('RefList'), t.lit('Text'));
const exportedTypeSuite: t.ITypeSuite = {
GristObjCode,
CellValue,
RowRecord,
GristType,
};
export default exportedTypeSuite;

@ -21,3 +21,7 @@ export interface RowRecord {
id: number;
[colId: string]: CellValue;
}
export type GristType = 'Any' | 'Attachments' | 'Blob' | 'Bool' | 'Choice' | 'ChoiceList' |
'Date' | 'DateTime' |
'Id' | 'Int' | 'ManualSortPos' | 'Numeric' | 'PositionNumber' | 'Ref' | 'RefList' | 'Text';

@ -18,7 +18,8 @@
// tslint:disable:no-console
import { CustomSectionAPI, InteractionOptions } from './CustomSectionAPI';
import { ColumnsToMap, CustomSectionAPI, InteractionOptions, InteractionOptionsRequest,
WidgetColumnMap } from './CustomSectionAPI';
import { GristAPI, GristDocAPI, GristView, RPC_GRISTAPI_INTERFACE } from './GristAPI';
import { RowRecord } from './GristData';
import { ImportSource, ImportSourceAPI, InternalImportSourceAPI } from './InternalImportSourceAPI';
@ -70,6 +71,97 @@ export const docApi: GristDocAPI & GristView = {
export const on = rpc.on.bind(rpc);
// For custom widgets that support custom columns mappings store current configuration
// in a memory.
// Actual cached value. Undefined means that widget hasn't asked for configuration yet.
// Here we are storing serialized configuration instead of actual one, since widget can
// mutate returned value.
let _mappingsCache: WidgetColumnMap|null|undefined;
// Since widget needs to ask for mappings during onRecord and onRecords event, we will reuse
// current request if available;
let _activeRefreshReq: Promise<void>|null = null;
// Remember columns requested during ready call.
let _columnsToMap: ColumnsToMap|undefined;
async function getMappingsIfChanged(data: any): Promise<WidgetColumnMap|null> {
const uninitialized = _mappingsCache === undefined;
if (data.mappingsChange || uninitialized) {
// If no active request.
if (!_activeRefreshReq) {
// Request for new mappings.
_activeRefreshReq = sectionApi
.mappings()
// Store it in global variable.
.then(mappings => void (_mappingsCache = mappings))
// Clear current request variable.
.finally(() => _activeRefreshReq = null);
}
await _activeRefreshReq;
}
return _mappingsCache ? JSON.parse(JSON.stringify(_mappingsCache)) : null;
}
/**
* Renames columns in the result using columns mapping configuration passed in ready method.
* Returns null if not all required columns were mapped or not widget doesn't support
* custom column mapping.
*/
export function mapColumnNames(data: any, options = {
columns: _columnsToMap,
mappings: _mappingsCache
}) {
// If not column configuration was requested or
// table has no rows, return original data.
if (!options.columns) {
return data;
}
// If we haven't received columns configuration return null.
if (!options.mappings) {
return null;
}
// If we are renaming names for whole table, but it is empty, don't do anything.
if (Array.isArray(data) && data.length === 0) {
return data;
}
// Prepare convert function - a function that will take record returned from Grist
// and convert it to a new record with mapped field names;
// Convert function will consists of several transformations:
const transformations: ((from: any, to: any) => void)[] = [];
// First transformation is for copying id field:
transformations.push((from, to) => to.id = from.id);
// Helper function to test if a column was configured as optional.
function isOptional(col: string) {
return Boolean(
// Columns passed as strings are required.
!options.columns?.includes(col)
&& options.columns?.find(c => typeof c === 'object' && c?.name === col && c.optional)
);
}
// For each widget column in mapping.
for(const widgetCol in options.mappings) {
// Get column from Grist.
const gristCol = options.mappings[widgetCol];
// Copy column as series (multiple values)
if (Array.isArray(gristCol) && gristCol.length) {
transformations.push((from, to) => {
to[widgetCol] = gristCol.map(col => from[col]);
});
// Copy column directly under widget column name.
} else if (!Array.isArray(gristCol) && gristCol) {
transformations.push((from, to) => to[widgetCol] = from[gristCol]);
} else if (!isOptional(widgetCol)) {
// Column was not configured but was required.
return null;
}
}
// Finally assemble function to convert a single record.
const convert = (rec: any) => transformations.reduce((obj, tran) => { tran(rec, obj); return obj; }, {} as any);
// Transform all records (or a single one depending on the arguments).
return Array.isArray(data) ? data.map(convert) : convert(data);
}
// For custom widgets, add a handler that will be called whenever the
// row with the cursor changes - either by switching to a different row, or
// by some value within the row potentially changing. Handler may
@ -77,17 +169,16 @@ export const on = rpc.on.bind(rpc);
// any row.
// TODO: currently this will be called even if the content of a different row
// changes.
export function onRecord(callback: (data: RowRecord | null) => unknown) {
export function onRecord(callback: (data: RowRecord | null, mappings: WidgetColumnMap | null) => unknown) {
on('message', async function(msg) {
if (!msg.tableId || !msg.rowId) { return; }
const rec = await docApi.fetchSelectedRecord(msg.rowId);
callback(rec);
callback(rec, await getMappingsIfChanged(msg));
});
}
// For custom widgets, add a handler that will be called whenever the
// selected records change. Handler will be called with a list of records.
export function onRecords(callback: (data: RowRecord[]) => unknown) {
export function onRecords(callback: (data: RowRecord[], mappings: WidgetColumnMap | null) => unknown) {
on('message', async function(msg) {
if (!msg.tableId || !msg.dataChange) { return; }
const data = await docApi.fetchSelectedTable();
@ -100,7 +191,7 @@ export function onRecords(callback: (data: RowRecord[]) => unknown) {
}
rows.push(row);
}
callback(rows);
callback(rows, await getMappingsIfChanged(msg));
});
}
@ -146,14 +237,17 @@ export async function addImporter(name: string, path: string, mode: 'fullscreen'
});
}
interface ReadyPayload extends Omit<InteractionOptionsRequest, "hasCustomOptions"> {
/**
* Handler that will be called by Grist to open additional configuration panel inside the Custom Widget.
*/
onEditOptions: () => unknown;
}
/**
* Declare that a component is prepared to receive messages from the outside world.
* Grist will not attempt to communicate with it until this method is called.
*/
export function ready(settings?: {
requiredAccess?: string,
onEditOptions: () => unknown
}): void {
export function ready(settings?: ReadyPayload): void {
if (settings && settings.onEditOptions) {
rpc.registerFunc('editOptions', settings.onEditOptions);
}
@ -161,10 +255,13 @@ export function ready(settings?: {
void (async function() {
await rpc.sendReadyMessage();
if (settings) {
await sectionApi.configure({
requiredAccess : settings.requiredAccess,
hasCustomOptions: Boolean(settings.onEditOptions)
}).catch((err: unknown) => console.error(err));
const options = {
...(settings),
hasCustomOptions: Boolean(settings.onEditOptions),
};
delete options.onEditOptions;
_columnsToMap = options.columns;
await sectionApi.configure(options).catch((err: unknown) => console.error(err));
}
})();
}

@ -1,9 +1,9 @@
import {CellValue} from 'app/common/DocActions';
import {GristType} from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil';
import * as gristTypes from 'app/common/gristTypes';
import {NumberFormatOptions} from 'app/common/NumberFormat';
import {FormatOptions, formatUnknown, IsRightTypeFunc} from 'app/common/ValueFormatter';
import {GristType} from 'app/plugin/GristData';
import {decodeObject} from 'app/plugin/objtypes';
import {Style} from 'exceljs';
import * as moment from 'moment-timezone';

Loading…
Cancel
Save