(core) In custom widgets show placeholder content until all columns are mapped

Summary: Showing configuration screen when widget is not mapped

Test Plan: New test added

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4192
This commit is contained in:
Jarosław Sadziński
2024-02-22 18:09:39 +01:00
parent ca990bbfe6
commit 42d7e31d27
8 changed files with 459 additions and 205 deletions

View File

@@ -8,3 +8,35 @@ iframe.custom_view {
padding: 15px;
margin: 15px;
}
.custom_view_no_mapping {
padding: 15px;
margin: 15px;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
color: var(--grist-theme-text, var(--grist-color-dark));
}
.custom_view_no_mapping h1 {
max-width: 310px;
margin-bottom: 24px;
margin-top: 56px;
font-style: normal;
font-weight: 600;
font-size: 22px;
line-height: 26px;
text-align: center;
text-wrap: balance;
}
.custom_view_no_mapping p {
max-width: 310px;
font-style: normal;
font-weight: 400;
font-size: 13px;
line-height: 16px;
text-align: center;
}

View File

@@ -16,8 +16,10 @@ import {
WidgetFrame
} from 'app/client/components/WidgetFrame';
import {CustomSectionElement, ViewProcess} from 'app/client/lib/CustomSectionElement';
import {makeT} from 'app/client/lib/localization';
import {Disposable} from 'app/client/lib/dispose';
import dom from 'app/client/lib/dom';
import {makeTestId} from 'app/client/lib/domUtils';
import * as kd from 'app/client/lib/koDom';
import DataTableModel from 'app/client/models/DataTableModel';
import {ViewSectionRec} from 'app/client/models/DocModel';
@@ -28,12 +30,14 @@ import {closeRegisteredMenu} from 'app/client/ui2018/menus';
import {AccessLevel} from 'app/common/CustomWidget';
import {defaultLocale} from 'app/common/gutil';
import {PluginInstance} from 'app/common/PluginInstance';
import {getGristConfig} from 'app/common/urlUtils';
import {Events as BackboneEvents} from 'backbone';
import {dom as grains} from 'grainjs';
import * as ko from 'knockout';
import defaults = require('lodash/defaults');
const t = makeT('CustomView');
const testId = makeTestId('test-custom-widget-');
/**
*
* Built in settings for a custom widget. Used when the custom
@@ -104,6 +108,7 @@ export class CustomView extends Disposable {
private _pluginInstance: PluginInstance|undefined;
private _frame: WidgetFrame; // plugin frame (holding external page)
private _hasUnmappedColumns: ko.Computed<boolean>;
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel, { 'addNewRow': true });
@@ -124,6 +129,15 @@ export class CustomView extends Disposable {
this.autoDispose(this.customDef.sectionId.subscribe(this._updateCustomSection, this));
this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.hasFocus));
this._hasUnmappedColumns = this.autoDispose(ko.pureComputed(() => {
const columns = this.viewSection.columnsToMap();
if (!columns) { return false; }
const required = columns.filter(col => typeof col === 'string' || !(col.optional === true))
.map(col => typeof col === 'string' ? col : col.name);
const mapped = this.viewSection.mappedColumns() || {};
return required.some(col => !mapped[col]) && this.customDef.mode() === "url";
}));
this.viewPane = this.autoDispose(this._buildDom());
this._updatePluginInstance();
}
@@ -138,10 +152,6 @@ export class CustomView extends Disposable {
return {};
}
protected getEmptyWidgetPage(): string {
return new URL("custom-widget.html", getGristConfig().homeUrl!).href;
}
/**
* Find a plugin instance that matches the plugin id, update the `found` observables, then tries to
* find a matching section.
@@ -207,11 +217,21 @@ export class CustomView extends Disposable {
dom.autoDispose(showPluginNotification),
dom.autoDispose(showSectionNotification),
dom.autoDispose(showPluginContent),
kd.maybe(this._hasUnmappedColumns, () => dom('div.custom_view_no_mapping',
testId('not-mapped'),
dom('img', {src: 'img/empty-widget.svg'}),
dom('h1', kd.text(t("Some required columns aren't mapped"))),
dom('p',
t('To use this widget, please map all non-optional columns from the creator panel on the right.')
),
)),
// todo: should display content in webview when running electron
// prefer widgetId; spelunk in widgetDef for older docs
kd.scope(() => [mode(), url(), access(), widgetId() || widgetDef()?.widgetId || '', pluginId()],
([_mode, _url, _access, _widgetId, _pluginId]: string[]) =>
_mode === "url" ?
kd.scope(() => [
this._hasUnmappedColumns(), mode(), url(), access(), widgetId() || widgetDef()?.widgetId || '', pluginId()
], ([_hide, _mode, _url, _access, _widgetId, _pluginId]: string[]) =>
_mode === "url" && !_hide ?
this._buildIFrame({
baseUrl: _url,
access: builtInSettings.accessLevel || (_access as AccessLevel || AccessLevel.none),
@@ -254,7 +274,7 @@ export class CustomView extends Disposable {
const documentSettings = this.gristDoc.docData.docSettings();
const readonly = this.gristDoc.isReadonly.get();
const widgetFrame = WidgetFrame.create(null, {
url: baseUrl || this.getEmptyWidgetPage(),
url: baseUrl,
widgetId,
pluginId,
access,

View File

@@ -12,6 +12,7 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions';
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes';
import {Theme} from 'app/common/ThemePrefs';
import {getGristConfig} from 'app/common/urlUtils';
import {
AccessTokenOptions, CursorPos, CustomSectionAPI, FetchSelectedOptions, GristDocAPI, GristView,
InteractionOptionsRequest, WidgetAPI, WidgetColumnMap
@@ -45,7 +46,7 @@ export interface WidgetFrameOptions {
/**
* Url of external page. Iframe is rebuild each time the URL changes.
*/
url: string;
url: string|null;
/**
* ID of widget, if known. When set, the url for the specified widget
* in the WidgetRepository, if found, will take precedence.
@@ -102,6 +103,12 @@ export class WidgetFrame extends DisposableWithEvents {
private _visible = Observable.create(this, !this._options.showAfterReady);
private readonly _widget = Observable.create<ICustomWidget|null>(this, null);
private _url: Observable<string>;
/**
* If the widget URL is empty, it also means that we are showing the empty page.
*/
private _isEmpty: Observable<boolean>;
constructor(private _options: WidgetFrameOptions) {
super();
_options.access = _options.access || AccessLevel.none;
@@ -129,6 +136,22 @@ export class WidgetFrame extends DisposableWithEvents {
_options.configure?.(this);
this._checkWidgetRepository().catch(reportError);
// Url if set.
const maybeUrl = Computed.create(this, use => use(this._widget)?.url || this._options.url);
// Url to widget or empty page with access level and preferences.
this._url = Computed.create(this, use => this._urlWithAccess(use(maybeUrl) || this._getEmptyWidgetPage()));
// Iframe is empty when url is not set.
this._isEmpty = Computed.create(this, use => !use(maybeUrl));
// When isEmpty is switched to true, reset the ready state.
this.autoDispose(this._isEmpty.addListener(isEmpty => {
if (isEmpty) {
this._readyCalled.set(false);
}
}));
}
/**
@@ -190,30 +213,30 @@ export class WidgetFrame extends DisposableWithEvents {
dom.style('visibility', use => use(this._visible) ? 'visible' : 'hidden'),
dom.cls('clipboard_focus'),
dom.cls('custom_view'),
dom.attr('src', use => this._getUrl(use(this._widget))),
dom.attr('src', this._url),
hooks.iframeAttributes,
testId('ready', this._readyCalled),
testId('ready', use => use(this._readyCalled) && !use(this._isEmpty)),
self => void onElem(self),
);
return this._iframe;
}
private _getUrl(widget: ICustomWidget|null): string {
// Append access level to query string.
const urlWithAccess = (url: string) => {
if (!url) {
return url;
}
const urlObj = new URL(url);
urlObj.searchParams.append('access', this._options.access);
urlObj.searchParams.append('readonly', String(this._options.readonly));
// Append user and document preferences to query string.
const settingsParams = new URLSearchParams(this._options.preferences);
settingsParams.forEach((value, key) => urlObj.searchParams.append(key, value));
return urlObj.href;
};
const url = widget?.url || this._options.url || 'about:blank';
return urlWithAccess(url);
// Appends access level to query string.
private _urlWithAccess(url: string) {
if (!url) {
return url;
}
const urlObj = new URL(url);
urlObj.searchParams.append('access', this._options.access);
urlObj.searchParams.append('readonly', String(this._options.readonly));
// Append user and document preferences to query string.
const settingsParams = new URLSearchParams(this._options.preferences);
settingsParams.forEach((value, key) => urlObj.searchParams.append(key, value));
return urlObj.href;
}
private _getEmptyWidgetPage(): string {
return new URL("custom-widget.html", getGristConfig().homeUrl!).href;
}
private _onMessage(event: MessageEvent) {