mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) Exposing custom widgets on the UI
Summary: Exposing custom widgets as a dropdown menu in custom section configuration panel. Adding new environmental variable GRIST_WIDGET_LIST_URL that points to a json file with an array of available widgets. When not present, custom widget menu is hidden, exposing only Custom URL option. Available widget list can be fetched from: https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json Test Plan: New tests, and updated old ones. Reviewers: paulfitz, dsagal Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3127
This commit is contained in:
		
							parent
							
								
									be96db4689
								
							
						
					
					
						commit
						1425461cd8
					
				@ -52,7 +52,9 @@ export class CursorMonitor extends Disposable {
 | 
				
			|||||||
    this.autoDispose(doc.cursorPosition.addListener(pos => {
 | 
					    this.autoDispose(doc.cursorPosition.addListener(pos => {
 | 
				
			||||||
      // if current position is not restored yet, don't change it
 | 
					      // if current position is not restored yet, don't change it
 | 
				
			||||||
      if (!this._restored) { return; }
 | 
					      if (!this._restored) { return; }
 | 
				
			||||||
      if (pos) { this._storePosition(pos); }
 | 
					      // store position only when we have valid rowId
 | 
				
			||||||
 | 
					      // for some views (like CustomView) cursor position might not reflect actual row
 | 
				
			||||||
 | 
					      if (pos && pos.rowId !== undefined) { this._storePosition(pos); }
 | 
				
			||||||
    }));
 | 
					    }));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -771,20 +771,7 @@ ViewConfigTab.prototype._buildCustomTypeItems = function() {
 | 
				
			|||||||
  }, {
 | 
					  }, {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // 2)
 | 
					    // 2)
 | 
				
			||||||
    showObs: () => activeSection().customDef.mode() === "url",
 | 
					    // TODO: refactor this part, Custom Widget moved to separate file.
 | 
				
			||||||
    buildDom: () => kd.scope(activeSection, ({customDef}) => dom('div',
 | 
					 | 
				
			||||||
      kf.row(18, kf.text(customDef.url, {placeholder: "Full URL of webpage to show"}, dom.testId('ViewConfigTab_url'))),
 | 
					 | 
				
			||||||
      kf.row(5, "Access", 13, dom(kf.select(customDef.access, ['none', 'read table', 'full']), dom.testId('ViewConfigTab_customView_access'))),
 | 
					 | 
				
			||||||
      kf.helpRow('none: widget has no access to document.',
 | 
					 | 
				
			||||||
                 kd.style('text-align', 'left'),
 | 
					 | 
				
			||||||
                 kd.style('margin-top', '1.5rem')),
 | 
					 | 
				
			||||||
      kf.helpRow('read table: widget can read the selected table.',
 | 
					 | 
				
			||||||
                 kd.style('text-align', 'left'),
 | 
					 | 
				
			||||||
                 kd.style('margin-top', '1.5rem')),
 | 
					 | 
				
			||||||
      kf.helpRow('full: widget can read, modify, and copy the document.',
 | 
					 | 
				
			||||||
                 kd.style('text-align', 'left'),
 | 
					 | 
				
			||||||
                 kd.style('margin-top', '1.5rem'))
 | 
					 | 
				
			||||||
    )),
 | 
					 | 
				
			||||||
  }, {
 | 
					  }, {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // 3)
 | 
					    // 3)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,7 @@
 | 
				
			|||||||
import * as BaseView from 'app/client/components/BaseView';
 | 
					import * as BaseView from 'app/client/components/BaseView';
 | 
				
			||||||
import { ColumnRec, FilterRec, TableRec, ViewFieldRec, ViewRec } from 'app/client/models/DocModel';
 | 
					import { ColumnRec, FilterRec, TableRec, ViewFieldRec, ViewRec } from 'app/client/models/DocModel';
 | 
				
			||||||
import * as modelUtil from 'app/client/models/modelUtil';
 | 
					import * as modelUtil from 'app/client/models/modelUtil';
 | 
				
			||||||
 | 
					import {ICustomWidget} from 'app/common/CustomWidget';
 | 
				
			||||||
import * as ko from 'knockout';
 | 
					import * as ko from 'knockout';
 | 
				
			||||||
import { CursorPos, } from 'app/client/components/Cursor';
 | 
					import { CursorPos, } from 'app/client/components/Cursor';
 | 
				
			||||||
import { KoArray, } from 'app/client/lib/koArray';
 | 
					import { KoArray, } from 'app/client/lib/koArray';
 | 
				
			||||||
@ -131,29 +132,36 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  // Apply `filter` to the field or column identified by `colRef`.
 | 
					  // Apply `filter` to the field or column identified by `colRef`.
 | 
				
			||||||
  setFilter(colRef: number, filter: string): void;
 | 
					  setFilter(colRef: number, filter: string): void;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Saves custom definition (bundles change)
 | 
				
			||||||
 | 
					  saveCustomDef(): Promise<void>;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface CustomViewSectionDef {
 | 
					export interface CustomViewSectionDef {
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * The mode.
 | 
					   * The mode.
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  mode: ko.Observable<"url"|"plugin">;
 | 
					  mode: modelUtil.KoSaveableObservable<"url"|"plugin">;
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * The url.
 | 
					   * The url.
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  url: ko.Observable<string>;
 | 
					  url: modelUtil.KoSaveableObservable<string|null>;
 | 
				
			||||||
 | 
					   /**
 | 
				
			||||||
 | 
					   * Custom widget information.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  widgetDef: modelUtil.KoSaveableObservable<ICustomWidget|null>;
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Access granted to url.
 | 
					   * Access granted to url.
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  access: ko.Observable<string>;
 | 
					  access: modelUtil.KoSaveableObservable<string>;
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * The plugin id.
 | 
					   * The plugin id.
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  pluginId: ko.Observable<string>;
 | 
					  pluginId: modelUtil.KoSaveableObservable<string>;
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * The section id.
 | 
					   * The section id.
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  sectionId: ko.Observable<string>;
 | 
					  sectionId: modelUtil.KoSaveableObservable<string>;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Information about filters for a field or hidden column.
 | 
					// Information about filters for a field or hidden column.
 | 
				
			||||||
@ -185,7 +193,8 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const customViewDefaults = {
 | 
					  const customViewDefaults = {
 | 
				
			||||||
    mode: 'url',
 | 
					    mode: 'url',
 | 
				
			||||||
    url: '',
 | 
					    url: null,
 | 
				
			||||||
 | 
					    widgetDef: null,
 | 
				
			||||||
    access: '',
 | 
					    access: '',
 | 
				
			||||||
    pluginId: '',
 | 
					    pluginId: '',
 | 
				
			||||||
    sectionId: ''
 | 
					    sectionId: ''
 | 
				
			||||||
@ -196,11 +205,16 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
 | 
				
			|||||||
  this.customDef = {
 | 
					  this.customDef = {
 | 
				
			||||||
    mode: customDefObj.prop('mode'),
 | 
					    mode: customDefObj.prop('mode'),
 | 
				
			||||||
    url: customDefObj.prop('url'),
 | 
					    url: customDefObj.prop('url'),
 | 
				
			||||||
 | 
					    widgetDef: customDefObj.prop('widgetDef'),
 | 
				
			||||||
    access: customDefObj.prop('access'),
 | 
					    access: customDefObj.prop('access'),
 | 
				
			||||||
    pluginId: customDefObj.prop('pluginId'),
 | 
					    pluginId: customDefObj.prop('pluginId'),
 | 
				
			||||||
    sectionId: customDefObj.prop('sectionId')
 | 
					    sectionId: customDefObj.prop('sectionId')
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  this.saveCustomDef = () => {
 | 
				
			||||||
 | 
					    return customDefObj.save();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  this.themeDef = modelUtil.fieldWithDefault(this.theme, 'form');
 | 
					  this.themeDef = modelUtil.fieldWithDefault(this.theme, 'form');
 | 
				
			||||||
  this.chartTypeDef = modelUtil.fieldWithDefault(this.chartType, 'bar');
 | 
					  this.chartTypeDef = modelUtil.fieldWithDefault(this.chartType, 'bar');
 | 
				
			||||||
  this.view = refRecord(docModel.views, this.parentId);
 | 
					  this.view = refRecord(docModel.views, this.parentId);
 | 
				
			||||||
 | 
				
			|||||||
@ -123,7 +123,12 @@ export function reportError(err: Error|string): void {
 | 
				
			|||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      // If we don't recognize it, consider it an application error (bug) that the user should be
 | 
					      // If we don't recognize it, consider it an application error (bug) that the user should be
 | 
				
			||||||
      // able to report.
 | 
					      // able to report.
 | 
				
			||||||
      _notifier.createAppError(err);
 | 
					      if (details?.userError) {
 | 
				
			||||||
 | 
					        // If we have user friendly error, show it instead.
 | 
				
			||||||
 | 
					        _notifier.createAppError(Error(details.userError));
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        _notifier.createAppError(err);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										280
									
								
								app/client/ui/CustomSectionConfig.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										280
									
								
								app/client/ui/CustomSectionConfig.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,280 @@
 | 
				
			|||||||
 | 
					import * as kf from 'app/client/lib/koForm';
 | 
				
			||||||
 | 
					import {ViewSectionRec} from 'app/client/models/DocModel';
 | 
				
			||||||
 | 
					import {reportError} from 'app/client/models/errors';
 | 
				
			||||||
 | 
					import {cssLabel, cssRow, cssTextInput} from 'app/client/ui/RightPanel';
 | 
				
			||||||
 | 
					import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
 | 
				
			||||||
 | 
					import {colors} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
 | 
					import {cssLink} from 'app/client/ui2018/links';
 | 
				
			||||||
 | 
					import {IOptionFull, select} from 'app/client/ui2018/menus';
 | 
				
			||||||
 | 
					import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget';
 | 
				
			||||||
 | 
					import {GristLoadConfig} from 'app/common/gristUrls';
 | 
				
			||||||
 | 
					import {nativeCompare} from 'app/common/gutil';
 | 
				
			||||||
 | 
					import {UserAPI} from 'app/common/UserAPI';
 | 
				
			||||||
 | 
					import {bundleChanges, Computed, Disposable, dom,
 | 
				
			||||||
 | 
					        makeTestId, MultiHolder, Observable, styled} from 'grainjs';
 | 
				
			||||||
 | 
					import {icon} from 'app/client/ui2018/icons';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Custom URL widget id - used as mock id for selectbox.
 | 
				
			||||||
 | 
					const CUSTOM_ID = "custom";
 | 
				
			||||||
 | 
					const testId = makeTestId('test-config-widget-');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Custom Widget section.
 | 
				
			||||||
 | 
					 * Allows to select custom widget from the list of available widgets
 | 
				
			||||||
 | 
					 * (taken from /widgets endpoint), or enter a Custom URL.
 | 
				
			||||||
 | 
					 * When Custom Widget has a desired access level (in accessLevel field),
 | 
				
			||||||
 | 
					 * will prompt user to approve it. "None" access level is auto approved,
 | 
				
			||||||
 | 
					 * so prompt won't be shown.
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * When gristConfig.enableWidgetRepository is set to false, it will only
 | 
				
			||||||
 | 
					 * allow to specify Custom URL.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class CustomSectionConfig extends Disposable {
 | 
				
			||||||
 | 
					  // Holds all available widget definitions.
 | 
				
			||||||
 | 
					  private _widgets: Observable<ICustomWidget[]>;
 | 
				
			||||||
 | 
					  // Holds selected option (either custom or a widgetId).
 | 
				
			||||||
 | 
					  private _selected: Computed<string|null>;
 | 
				
			||||||
 | 
					  // Holds custom widget URL.
 | 
				
			||||||
 | 
					  private _url: Computed<string>;
 | 
				
			||||||
 | 
					  // Enable or disable widget repository.
 | 
				
			||||||
 | 
					  private _canSelect = true;
 | 
				
			||||||
 | 
					  // Selected access level.
 | 
				
			||||||
 | 
					  private _selectedAccess: Computed<AccessLevel>;
 | 
				
			||||||
 | 
					  // When widget is changed, it sets its desired access level. We will prompt
 | 
				
			||||||
 | 
					  // user to approve or reject it.
 | 
				
			||||||
 | 
					  private _desiredAccess: Observable<AccessLevel>;
 | 
				
			||||||
 | 
					  // Current access level (stored inside a section).
 | 
				
			||||||
 | 
					  private _currentAccess: Computed<AccessLevel>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor(section: ViewSectionRec, api: UserAPI) {
 | 
				
			||||||
 | 
					    super();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Test if we can offer widget list.
 | 
				
			||||||
 | 
					    const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
 | 
				
			||||||
 | 
					    this._canSelect = gristConfig.enableWidgetRepository ?? true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Array of available widgets - will be updated asynchronously.
 | 
				
			||||||
 | 
					    this._widgets = Observable.create(this, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (this._canSelect) {
 | 
				
			||||||
 | 
					      // From the start we will provide single widget definition
 | 
				
			||||||
 | 
					      // that was chosen previously.
 | 
				
			||||||
 | 
					      if (section.customDef.widgetDef.peek()) {
 | 
				
			||||||
 | 
					        this._widgets.set([section.customDef.widgetDef.peek()!]);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      // Request for rest of the widgets.
 | 
				
			||||||
 | 
					      api.getWidgets().then(widgets => {
 | 
				
			||||||
 | 
					        if (this.isDisposed()) {
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        const existing = section.customDef.widgetDef.peek();
 | 
				
			||||||
 | 
					        // Make sure we have current widget in place.
 | 
				
			||||||
 | 
					        if (existing && !widgets.some(w => w.widgetId === existing.widgetId)) {
 | 
				
			||||||
 | 
					          widgets.push(existing);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this._widgets.set(widgets.sort((a, b) => nativeCompare(a.name.toLowerCase(), b.name.toLowerCase())));
 | 
				
			||||||
 | 
					      }).catch(err => {
 | 
				
			||||||
 | 
					        reportError(err);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 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._selected = 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;
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    this._selected.onWrite(async (value) => {
 | 
				
			||||||
 | 
					      if (value === CUSTOM_ID) {
 | 
				
			||||||
 | 
					        // Select Custom URL
 | 
				
			||||||
 | 
					        bundleChanges(() => {
 | 
				
			||||||
 | 
					          // Clear url.
 | 
				
			||||||
 | 
					          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);
 | 
				
			||||||
 | 
					          this._desiredAccess.set(AccessLevel.none);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        await section.saveCustomDef();
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        // Select Widget
 | 
				
			||||||
 | 
					        const selectedWidget = this._widgets.get().find(w => w.widgetId === value);
 | 
				
			||||||
 | 
					        if (!selectedWidget) {
 | 
				
			||||||
 | 
					          // should not happen
 | 
				
			||||||
 | 
					          throw new Error("Error accessing widget from the list");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // If user selected the same one, do nothing.
 | 
				
			||||||
 | 
					        if (section.customDef.widgetDef.peek()?.widgetId === value) {
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        bundleChanges(() => {
 | 
				
			||||||
 | 
					          // Clear access level
 | 
				
			||||||
 | 
					          section.customDef.access(AccessLevel.none);
 | 
				
			||||||
 | 
					          // When widget wants some access, set desired access level.
 | 
				
			||||||
 | 
					          this._desiredAccess.set(selectedWidget.accessLevel || AccessLevel.none);
 | 
				
			||||||
 | 
					          // Update widget definition.
 | 
				
			||||||
 | 
					          section.customDef.widgetDef(selectedWidget);
 | 
				
			||||||
 | 
					          // Update widget URL.
 | 
				
			||||||
 | 
					          section.customDef.url(selectedWidget.url);
 | 
				
			||||||
 | 
					          // Clear intermediate state.
 | 
				
			||||||
 | 
					          wantsToBeCustom.set(false);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        await section.saveCustomDef();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Url for the widget, taken either from widget definition, or provided by hand for Custom URL.
 | 
				
			||||||
 | 
					    // For custom widget, we will store url also in section definition.
 | 
				
			||||||
 | 
					    this._url = Computed.create(this, use => use(section.customDef.url) || "");
 | 
				
			||||||
 | 
					    this._url.onWrite((newUrl) => section.customDef.url.setAndSave(newUrl));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Compute current access level.
 | 
				
			||||||
 | 
					    this._currentAccess = Computed.create(this,
 | 
				
			||||||
 | 
					      use => use(section.customDef.access) as AccessLevel || AccessLevel.none);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // From the start desired access level is the same as current one.
 | 
				
			||||||
 | 
					    this._desiredAccess = Observable.create(this, this._currentAccess.get());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Selected access level will show desired one, but will updated both (desired and current).
 | 
				
			||||||
 | 
					    this._selectedAccess = Computed.create(this, use => use(this._desiredAccess));
 | 
				
			||||||
 | 
					    this._selectedAccess.onWrite(async newAccess => {
 | 
				
			||||||
 | 
					      this._desiredAccess.set(newAccess);
 | 
				
			||||||
 | 
					      await section.customDef.access.setAndSave(newAccess);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Clear intermediate state when section changes.
 | 
				
			||||||
 | 
					    this.autoDispose(section.id.subscribe(() => wantsToBeCustom.set(false)));
 | 
				
			||||||
 | 
					    this.autoDispose(section.id.subscribe(() => this._reject()));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public buildDom() {
 | 
				
			||||||
 | 
					    // UI observables holder.
 | 
				
			||||||
 | 
					    const holder = new MultiHolder();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Show prompt, when desired access level is different from actual one.
 | 
				
			||||||
 | 
					    const prompt = Computed.create(holder, use => use(this._currentAccess) !== use(this._desiredAccess));
 | 
				
			||||||
 | 
					    // If this is empty section or not.
 | 
				
			||||||
 | 
					    const isSelected = Computed.create(holder, use => Boolean(use(this._selected)));
 | 
				
			||||||
 | 
					    // If user is using custom url.
 | 
				
			||||||
 | 
					    const isCustom = Computed.create(holder, use => use(this._selected) === CUSTOM_ID || !this._canSelect);
 | 
				
			||||||
 | 
					    // Options for the selectbox (all widgets definitions and Custom URL)
 | 
				
			||||||
 | 
					    const options = Computed.create(holder, use => [
 | 
				
			||||||
 | 
					      {label: 'Custom URL', value: 'custom'},
 | 
				
			||||||
 | 
					      ...use(this._widgets).map(w => ({label: w.name, value: w.widgetId})),
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
 | 
					    // Options for access level.
 | 
				
			||||||
 | 
					    const levels: IOptionFull<string>[] = [
 | 
				
			||||||
 | 
					      {label: 'No document access', value: AccessLevel.none},
 | 
				
			||||||
 | 
					      {label: 'Read selected table', value: AccessLevel.read_table},
 | 
				
			||||||
 | 
					      {label: 'Full document access', value: AccessLevel.full},
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					    return dom(
 | 
				
			||||||
 | 
					      'div',
 | 
				
			||||||
 | 
					      dom.autoDispose(holder),
 | 
				
			||||||
 | 
					      this._canSelect ?
 | 
				
			||||||
 | 
					      cssRow(
 | 
				
			||||||
 | 
					        select(this._selected, options, {
 | 
				
			||||||
 | 
					          defaultLabel: 'Select Custom Widget',
 | 
				
			||||||
 | 
					          menuCssClass: cssMenu.className
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					        testId('select')
 | 
				
			||||||
 | 
					      ) : null,
 | 
				
			||||||
 | 
					      dom.maybe(isCustom, () => [
 | 
				
			||||||
 | 
					        cssRow(
 | 
				
			||||||
 | 
					          cssTextInput(
 | 
				
			||||||
 | 
					            this._url,
 | 
				
			||||||
 | 
					            async value => this._url.set(value),
 | 
				
			||||||
 | 
					            dom.attr('placeholder', 'Enter Custom URL'),
 | 
				
			||||||
 | 
					            testId('url')
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ]),
 | 
				
			||||||
 | 
					      cssSection(
 | 
				
			||||||
 | 
					        cssLink(
 | 
				
			||||||
 | 
					          dom.attr('href', 'https://support.getgrist.com/widget-custom'),
 | 
				
			||||||
 | 
					          dom.attr('target', '_blank'),
 | 
				
			||||||
 | 
					          'Learn more about custom widgets'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      dom.maybe((use) => use(isSelected) || !this._canSelect, () => [
 | 
				
			||||||
 | 
					        cssLabel('ACCESS LEVEL'),
 | 
				
			||||||
 | 
					        cssRow(select(this._selectedAccess, levels), testId('access')),
 | 
				
			||||||
 | 
					        dom.maybe(prompt, () =>
 | 
				
			||||||
 | 
					          kf.prompt(
 | 
				
			||||||
 | 
					            {tabindex: '-1'},
 | 
				
			||||||
 | 
					            cssColumns(
 | 
				
			||||||
 | 
					              cssWarningWrapper(
 | 
				
			||||||
 | 
					                icon('Lock'),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              dom('div',
 | 
				
			||||||
 | 
					                cssConfirmRow(
 | 
				
			||||||
 | 
					                  "Approve requested access level?"
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                cssConfirmRow(
 | 
				
			||||||
 | 
					                  primaryButton("Accept",
 | 
				
			||||||
 | 
					                    testId('access-accept'),
 | 
				
			||||||
 | 
					                    dom.on('click', () => this._accept())),
 | 
				
			||||||
 | 
					                  basicButton("Reject",
 | 
				
			||||||
 | 
					                    testId('access-reject'),
 | 
				
			||||||
 | 
					                    dom.on('click', () => this._reject()))
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					              )
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      ])
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private _accept() {
 | 
				
			||||||
 | 
					    this._selectedAccess.set(this._desiredAccess.get());
 | 
				
			||||||
 | 
					    this._reject();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private _reject() {
 | 
				
			||||||
 | 
					    this._desiredAccess.set(this._currentAccess.get());
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const cssWarningWrapper = styled('div', `
 | 
				
			||||||
 | 
					  padding-left: 8px;
 | 
				
			||||||
 | 
					  padding-top: 6px;
 | 
				
			||||||
 | 
					  --icon-color: ${colors.lightGreen}
 | 
				
			||||||
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const cssColumns = styled('div', `
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const cssConfirmRow = styled('div', `
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  padding: 8px;
 | 
				
			||||||
 | 
					  gap: 8px;
 | 
				
			||||||
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const cssSection = styled('div', `
 | 
				
			||||||
 | 
					  margin: 16px 16px 12px 16px;
 | 
				
			||||||
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const cssMenu = styled('div', `
 | 
				
			||||||
 | 
					  & > li:first-child {
 | 
				
			||||||
 | 
					    border-bottom: 1px solid ${colors.mediumGrey};
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					`);
 | 
				
			||||||
@ -40,6 +40,7 @@ import {bundleChanges, Computed, Disposable, dom, domComputed, DomContents,
 | 
				
			|||||||
        DomElementArg, DomElementMethod, IDomComponent} from 'grainjs';
 | 
					        DomElementArg, DomElementMethod, IDomComponent} from 'grainjs';
 | 
				
			||||||
import {MultiHolder, Observable, styled, subscribe} from 'grainjs';
 | 
					import {MultiHolder, Observable, styled, subscribe} from 'grainjs';
 | 
				
			||||||
import * as ko from 'knockout';
 | 
					import * as ko from 'knockout';
 | 
				
			||||||
 | 
					import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Represents a top tab of the right side-pane.
 | 
					// Represents a top tab of the right side-pane.
 | 
				
			||||||
const TopTab = StringUnion("pageWidget", "field");
 | 
					const TopTab = StringUnion("pageWidget", "field");
 | 
				
			||||||
@ -337,7 +338,7 @@ export class RightPanel extends Disposable {
 | 
				
			|||||||
            // In the default url mode, allow picking a url and granting/forbidding
 | 
					            // In the default url mode, allow picking a url and granting/forbidding
 | 
				
			||||||
            // access to data.
 | 
					            // access to data.
 | 
				
			||||||
            dom.maybe(use => use(activeSection.customDef.mode) === 'url',
 | 
					            dom.maybe(use => use(activeSection.customDef.mode) === 'url',
 | 
				
			||||||
                      () => dom('div', parts[1].buildDom())),
 | 
					                      () => dom.create(CustomSectionConfig, activeSection, this._gristDoc.app.topAppModel.api)),
 | 
				
			||||||
          ];
 | 
					          ];
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      ]),
 | 
					      ]),
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										39
									
								
								app/common/CustomWidget.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/common/CustomWidget.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,39 @@
 | 
				
			|||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Custom widget manifest definition.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export interface ICustomWidget {
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Widget friendly name, used on the UI.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  name: string;
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Widget unique id, probably in npm package format @gristlabs/custom-widget-name.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  widgetId: string;
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Custom widget main page URL.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  url: string;
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Optional desired access level.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  accessLevel?: AccessLevel;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Widget access level.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export enum AccessLevel {
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Default, no access to Grist.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  none = "none",
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Read only access to table the widget is based on.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  read_table = "read table",
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Full access to document on user's behalf.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  full = "full",
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -6,6 +6,7 @@ import {BrowserSettings} from 'app/common/BrowserSettings';
 | 
				
			|||||||
import {BulkColValues, TableColValues, UserAction} from 'app/common/DocActions';
 | 
					import {BulkColValues, TableColValues, UserAction} from 'app/common/DocActions';
 | 
				
			||||||
import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
 | 
					import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
 | 
				
			||||||
import {Features} from 'app/common/Features';
 | 
					import {Features} from 'app/common/Features';
 | 
				
			||||||
 | 
					import {ICustomWidget} from 'app/common/CustomWidget';
 | 
				
			||||||
import {isClient} from 'app/common/gristUrls';
 | 
					import {isClient} from 'app/common/gristUrls';
 | 
				
			||||||
import {FullUser} from 'app/common/LoginSessionAPI';
 | 
					import {FullUser} from 'app/common/LoginSessionAPI';
 | 
				
			||||||
import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs';
 | 
					import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs';
 | 
				
			||||||
@ -321,6 +322,7 @@ export interface UserAPI {
 | 
				
			|||||||
  deleteUser(userId: number, name: string): Promise<void>;
 | 
					  deleteUser(userId: number, name: string): Promise<void>;
 | 
				
			||||||
  getBaseUrl(): string;  // Get the prefix for all the endpoints this object wraps.
 | 
					  getBaseUrl(): string;  // Get the prefix for all the endpoints this object wraps.
 | 
				
			||||||
  forRemoved(): UserAPI; // Get a version of the API that works on removed resources.
 | 
					  forRemoved(): UserAPI; // Get a version of the API that works on removed resources.
 | 
				
			||||||
 | 
					  getWidgets(): Promise<ICustomWidget[]>;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
@ -428,6 +430,10 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
 | 
				
			|||||||
    return this.requestJson(`${this._url}/api/templates?onlyFeatured=${onlyFeatured ? 1 : 0}`, { method: 'GET' });
 | 
					    return this.requestJson(`${this._url}/api/templates?onlyFeatured=${onlyFeatured ? 1 : 0}`, { method: 'GET' });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async getWidgets(): Promise<ICustomWidget[]> {
 | 
				
			||||||
 | 
					    return await this.requestJson(`${this._url}/api/widgets`, { method: 'GET' });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async getDoc(docId: string): Promise<Document> {
 | 
					  public async getDoc(docId: string): Promise<Document> {
 | 
				
			||||||
    return this.requestJson(`${this._url}/api/docs/${docId}`, { method: 'GET' });
 | 
					    return this.requestJson(`${this._url}/api/docs/${docId}`, { method: 'GET' });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
				
			|||||||
@ -469,6 +469,9 @@ export interface GristLoadConfig {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  // List of registered plugins (used by HomePluginManager and DocPluginManager)
 | 
					  // List of registered plugins (used by HomePluginManager and DocPluginManager)
 | 
				
			||||||
  plugins?: LocalPlugin[];
 | 
					  plugins?: LocalPlugin[];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // If custom widget list is available.
 | 
				
			||||||
 | 
					  enableWidgetRepository?: boolean;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Acceptable org subdomains are alphanumeric (hyphen also allowed) and of
 | 
					// Acceptable org subdomains are alphanumeric (hyphen also allowed) and of
 | 
				
			||||||
 | 
				
			|||||||
@ -12,6 +12,7 @@ import {RequestWithOrg} from 'app/server/lib/extractOrg';
 | 
				
			|||||||
import * as log from 'app/server/lib/log';
 | 
					import * as log from 'app/server/lib/log';
 | 
				
			||||||
import {addPermit, getDocScope, getScope, integerParam, isParameterOn, sendOkReply,
 | 
					import {addPermit, getDocScope, getScope, integerParam, isParameterOn, sendOkReply,
 | 
				
			||||||
        sendReply, stringParam} from 'app/server/lib/requestUtils';
 | 
					        sendReply, stringParam} from 'app/server/lib/requestUtils';
 | 
				
			||||||
 | 
					import {IWidgetRepository} from 'app/server/lib/WidgetRepository';
 | 
				
			||||||
import {Request} from 'express';
 | 
					import {Request} from 'express';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import {User} from './entity/User';
 | 
					import {User} from './entity/User';
 | 
				
			||||||
@ -98,7 +99,8 @@ export class ApiServer {
 | 
				
			|||||||
   */
 | 
					   */
 | 
				
			||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
    private _app: express.Application,
 | 
					    private _app: express.Application,
 | 
				
			||||||
    private _dbManager: HomeDBManager
 | 
					    private _dbManager: HomeDBManager,
 | 
				
			||||||
 | 
					    private _widgetRepository: IWidgetRepository
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
    this._addEndpoints();
 | 
					    this._addEndpoints();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -238,6 +240,13 @@ export class ApiServer {
 | 
				
			|||||||
      return sendReply(req, res, query);
 | 
					      return sendReply(req, res, query);
 | 
				
			||||||
    }));
 | 
					    }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // GET /api/widgets/
 | 
				
			||||||
 | 
					    // Get all widget definitions from external source.
 | 
				
			||||||
 | 
					    this._app.get('/api/widgets/', expressWrap(async (req, res) => {
 | 
				
			||||||
 | 
					      const widgetList = await this._widgetRepository.getWidgets();
 | 
				
			||||||
 | 
					      return sendOkReply(req, res, widgetList);
 | 
				
			||||||
 | 
					    }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // PATCH /api/docs/:did
 | 
					    // PATCH /api/docs/:did
 | 
				
			||||||
    // Update the specified doc.
 | 
					    // Update the specified doc.
 | 
				
			||||||
    this._app.patch('/api/docs/:did', expressWrap(async (req, res) => {
 | 
					    this._app.patch('/api/docs/:did', expressWrap(async (req, res) => {
 | 
				
			||||||
 | 
				
			|||||||
@ -54,6 +54,7 @@ import * as shutdown from 'app/server/lib/shutdown';
 | 
				
			|||||||
import {TagChecker} from 'app/server/lib/TagChecker';
 | 
					import {TagChecker} from 'app/server/lib/TagChecker';
 | 
				
			||||||
import {startTestingHooks} from 'app/server/lib/TestingHooks';
 | 
					import {startTestingHooks} from 'app/server/lib/TestingHooks';
 | 
				
			||||||
import {addUploadRoute} from 'app/server/lib/uploads';
 | 
					import {addUploadRoute} from 'app/server/lib/uploads';
 | 
				
			||||||
 | 
					import {buildWidgetRepository, IWidgetRepository} from 'app/server/lib/WidgetRepository';
 | 
				
			||||||
import axios from 'axios';
 | 
					import axios from 'axios';
 | 
				
			||||||
import * as bodyParser from 'body-parser';
 | 
					import * as bodyParser from 'body-parser';
 | 
				
			||||||
import * as express from 'express';
 | 
					import * as express from 'express';
 | 
				
			||||||
@ -121,6 +122,7 @@ export class FlexServer implements GristServer {
 | 
				
			|||||||
  private _sessionStore: SessionStore;
 | 
					  private _sessionStore: SessionStore;
 | 
				
			||||||
  private _storageManager: IDocStorageManager;
 | 
					  private _storageManager: IDocStorageManager;
 | 
				
			||||||
  private _docWorkerMap: IDocWorkerMap;
 | 
					  private _docWorkerMap: IDocWorkerMap;
 | 
				
			||||||
 | 
					  private _widgetRepository: IWidgetRepository;
 | 
				
			||||||
  private _internalPermitStore: IPermitStore;  // store for permits that stay within our servers
 | 
					  private _internalPermitStore: IPermitStore;  // store for permits that stay within our servers
 | 
				
			||||||
  private _externalPermitStore: IPermitStore;  // store for permits that pass through outside servers
 | 
					  private _externalPermitStore: IPermitStore;  // store for permits that pass through outside servers
 | 
				
			||||||
  private _disabled: boolean = false;
 | 
					  private _disabled: boolean = false;
 | 
				
			||||||
@ -271,6 +273,11 @@ export class FlexServer implements GristServer {
 | 
				
			|||||||
    return this._storageManager;
 | 
					    return this._storageManager;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public getWidgetRepository(): IWidgetRepository {
 | 
				
			||||||
 | 
					    if (!this._widgetRepository) { throw new Error('no widget repository available'); }
 | 
				
			||||||
 | 
					    return this._widgetRepository;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public addLogging() {
 | 
					  public addLogging() {
 | 
				
			||||||
    if (this._check('logging')) { return; }
 | 
					    if (this._check('logging')) { return; }
 | 
				
			||||||
    if (process.env.GRIST_LOG_SKIP_HTTP) { return; }
 | 
					    if (process.env.GRIST_LOG_SKIP_HTTP) { return; }
 | 
				
			||||||
@ -524,7 +531,7 @@ export class FlexServer implements GristServer {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    // ApiServer's constructor adds endpoints to the app.
 | 
					    // ApiServer's constructor adds endpoints to the app.
 | 
				
			||||||
    // tslint:disable-next-line:no-unused-expression
 | 
					    // tslint:disable-next-line:no-unused-expression
 | 
				
			||||||
    new ApiServer(this.app, this._dbManager);
 | 
					    new ApiServer(this.app, this._dbManager, this._widgetRepository = buildWidgetRepository());
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public addBillingApi() {
 | 
					  public addBillingApi() {
 | 
				
			||||||
 | 
				
			|||||||
@ -19,6 +19,7 @@ export const ITestingHooks = t.iface([], {
 | 
				
			|||||||
  "getDocClientCounts": t.func(t.array(t.tuple("string", "number"))),
 | 
					  "getDocClientCounts": t.func(t.array(t.tuple("string", "number"))),
 | 
				
			||||||
  "setActiveDocTimeout": t.func("number", t.param("seconds", "number")),
 | 
					  "setActiveDocTimeout": t.func("number", t.param("seconds", "number")),
 | 
				
			||||||
  "setDiscourseConnectVar": t.func(t.union("string", "null"), t.param("varName", "string"), t.param("value", t.union("string", "null"))),
 | 
					  "setDiscourseConnectVar": t.func(t.union("string", "null"), t.param("varName", "string"), t.param("value", t.union("string", "null"))),
 | 
				
			||||||
 | 
					  "setWidgetRepositoryUrl": t.func("void", t.param("url", "string")),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const exportedTypeSuite: t.ITypeSuite = {
 | 
					const exportedTypeSuite: t.ITypeSuite = {
 | 
				
			||||||
 | 
				
			|||||||
@ -15,4 +15,5 @@ export interface ITestingHooks {
 | 
				
			|||||||
  getDocClientCounts(): Promise<Array<[string, number]>>;
 | 
					  getDocClientCounts(): Promise<Array<[string, number]>>;
 | 
				
			||||||
  setActiveDocTimeout(seconds: number): Promise<number>;
 | 
					  setActiveDocTimeout(seconds: number): Promise<number>;
 | 
				
			||||||
  setDiscourseConnectVar(varName: string, value: string|null): Promise<string|null>;
 | 
					  setDiscourseConnectVar(varName: string, value: string|null): Promise<string|null>;
 | 
				
			||||||
 | 
					  setWidgetRepositoryUrl(url: string): Promise<void>;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -12,6 +12,7 @@ import {FlexServer} from './FlexServer';
 | 
				
			|||||||
import {ITestingHooks} from './ITestingHooks';
 | 
					import {ITestingHooks} from './ITestingHooks';
 | 
				
			||||||
import ITestingHooksTI from './ITestingHooks-ti';
 | 
					import ITestingHooksTI from './ITestingHooks-ti';
 | 
				
			||||||
import {connect, fromCallback} from './serverUtils';
 | 
					import {connect, fromCallback} from './serverUtils';
 | 
				
			||||||
 | 
					import {WidgetRepositoryImpl} from 'app/server/lib/WidgetRepository';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const tiCheckers = t.createCheckers(ITestingHooksTI, {UserProfile: t.name("object")});
 | 
					const tiCheckers = t.createCheckers(ITestingHooksTI, {UserProfile: t.name("object")});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -194,4 +195,12 @@ export class TestingHooks implements ITestingHooks {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
    return prev;
 | 
					    return prev;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async setWidgetRepositoryUrl(url: string): Promise<void> {
 | 
				
			||||||
 | 
					    const repo = this._server.getWidgetRepository() as WidgetRepositoryImpl;
 | 
				
			||||||
 | 
					    if (!(repo instanceof WidgetRepositoryImpl)) {
 | 
				
			||||||
 | 
					      throw new Error("Unsupported widget repository");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    repo.testOverrideUrl(url);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										92
									
								
								app/server/lib/WidgetRepository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								app/server/lib/WidgetRepository.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,92 @@
 | 
				
			|||||||
 | 
					import {ICustomWidget} from 'app/common/CustomWidget';
 | 
				
			||||||
 | 
					import * as log from 'app/server/lib/log';
 | 
				
			||||||
 | 
					import fetch from 'node-fetch';
 | 
				
			||||||
 | 
					import {ApiError} from 'app/common/ApiError';
 | 
				
			||||||
 | 
					import * as LRUCache from 'lru-cache';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Widget Repository returns list of available Custom Widgets.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export interface IWidgetRepository {
 | 
				
			||||||
 | 
					  getWidgets(): Promise<ICustomWidget[]>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Static url for StaticWidgetRepository
 | 
				
			||||||
 | 
					const STATIC_URL = process.env.GRIST_WIDGET_LIST_URL;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Default repository that gets list of available widgets from a static URL.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export class WidgetRepositoryImpl implements IWidgetRepository {
 | 
				
			||||||
 | 
					  constructor(protected _staticUrl = STATIC_URL) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Method exposed for testing, overrides widget url.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  public testOverrideUrl(url: string) {
 | 
				
			||||||
 | 
					    this._staticUrl = url;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async getWidgets(): Promise<ICustomWidget[]> {
 | 
				
			||||||
 | 
					    if (!this._staticUrl) {
 | 
				
			||||||
 | 
					      log.warn(
 | 
				
			||||||
 | 
					        'WidgetRepository: Widget repository is not configured.' + !STATIC_URL
 | 
				
			||||||
 | 
					          ? ' Missing GRIST_WIDGET_LIST_URL environmental variable.'
 | 
				
			||||||
 | 
					          : ''
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      return [];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const response = await fetch(this._staticUrl);
 | 
				
			||||||
 | 
					      if (!response.ok) {
 | 
				
			||||||
 | 
					        if (response.status === 404) {
 | 
				
			||||||
 | 
					          throw new ApiError('WidgetRepository: Remote widget list not found', 404);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          const body = await response.text().catch(() => '');
 | 
				
			||||||
 | 
					          throw new ApiError(
 | 
				
			||||||
 | 
					            `WidgetRepository: Remote server returned an error: ${body || response.statusText}`, response.status
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      const widgets = await response.json().catch(() => null);
 | 
				
			||||||
 | 
					      if (!widgets || !Array.isArray(widgets)) {
 | 
				
			||||||
 | 
					        throw new ApiError('WidgetRepository: Error reading widget list', 500);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return widgets;
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      if (!(err instanceof ApiError)) {
 | 
				
			||||||
 | 
					        throw new ApiError(String(err), 500);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      throw err;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Version of WidgetRepository that caches successful result for 2 minutes.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					class CachedWidgetRepository extends WidgetRepositoryImpl {
 | 
				
			||||||
 | 
					  private _cache = new LRUCache<1, ICustomWidget[]>({maxAge : 1000 * 60 /* minute */ * 2});
 | 
				
			||||||
 | 
					  public async getWidgets() {
 | 
				
			||||||
 | 
					    if (this._cache.has(1)) {
 | 
				
			||||||
 | 
					      log.debug("WidgetRepository: Widget list taken from the cache.");
 | 
				
			||||||
 | 
					      return this._cache.get(1)!;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const list = await super.getWidgets();
 | 
				
			||||||
 | 
					    // Cache only if there are some widgets.
 | 
				
			||||||
 | 
					    if (list.length) { this._cache.set(1, list); }
 | 
				
			||||||
 | 
					    return list;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public testOverrideUrl(url: string) {
 | 
				
			||||||
 | 
					    super.testOverrideUrl(url);
 | 
				
			||||||
 | 
					    this._cache.reset();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Returns widget repository implementation.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export function buildWidgetRepository() {
 | 
				
			||||||
 | 
					  return new CachedWidgetRepository();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -45,6 +45,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial<GristLoadCo
 | 
				
			|||||||
    maxUploadSizeImport: (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || undefined,
 | 
					    maxUploadSizeImport: (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || undefined,
 | 
				
			||||||
    maxUploadSizeAttachment: (Number(process.env.GRIST_MAX_UPLOAD_ATTACHMENT_MB) * 1024 * 1024) || undefined,
 | 
					    maxUploadSizeAttachment: (Number(process.env.GRIST_MAX_UPLOAD_ATTACHMENT_MB) * 1024 * 1024) || undefined,
 | 
				
			||||||
    timestampMs: Date.now(),
 | 
					    timestampMs: Date.now(),
 | 
				
			||||||
 | 
					    enableWidgetRepository: Boolean(process.env.GRIST_WIDGET_LIST_URL),
 | 
				
			||||||
    ...extra,
 | 
					    ...extra,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user