(core) Adds a UI panel for managing webhooks

Summary:
This adds a UI panel for managing webhooks. Work started by Cyprien Pindat. You can find the UI on a document's settings page. Main changes relative to Cyprien's demo:

  * Changed behavior of virtual table to be more consistent with the rest of Grist, by factoring out part of the implementation of on-demand tables.
  * Cell values that would create an error can now be denied and reverted (as for the rest of Grist).
  * Changes made by other users are integrated in a sane way.
  * Basic undo/redo support is added using the regular undo/redo stack.
  * The table list in the drop-down is now updated if schema changes.
  * Added a notification from back-end when webhook status is updated so constant polling isn't needed to support multi-user operation.
  *  Factored out webhook specific logic from general virtual table support.
  * Made a bunch of fixes to various broken behavior.
  * Added tests.

The code remains somewhat unpolished, and behavior in the presence of errors is imperfect in general but may be adequate for this case.

I assume that we'll soon be lifting the restriction on the set of domains that are supported for webhooks - otherwise we'd want to provide some friendly way to discover that list of supported domains rather than just throwing an error.

I don't actually know a lot about how the front-end works - it looks like tables/columns/fields/sections can be safely added if they have string ids that won't collide with bone fide numeric ids from the back end. Sneaky.

Contains a migration, so needs an extra reviewer for that.

Test Plan: added tests

Reviewers: jarek, dsagal

Reviewed By: jarek, dsagal

Differential Revision: https://phab.getgrist.com/D3856
pull/518/head
Paul Fitzpatrick 1 year ago
parent 5e9f2e06ea
commit 603238e966

@ -46,6 +46,7 @@ import {DocTutorial} from 'app/client/ui/DocTutorial';
import {isTourActive} from "app/client/ui/OnBoardingPopups";
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkFromId, selectBy} from 'app/client/ui/selectBy';
import {WebhookPage} from 'app/client/ui/WebhookPage';
import {startWelcomeTour} from 'app/client/ui/WelcomeTour';
import {IWidgetType} from 'app/client/ui/widgetTypes';
import {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer';
@ -57,7 +58,7 @@ import {FieldEditor} from "app/client/widgets/FieldEditor";
import {DiscussionPanel} from 'app/client/widgets/DiscussionEditor';
import {MinimalActionGroup} from 'app/common/ActionGroup';
import {ClientQuery} from "app/common/ActiveDocAPI";
import {CommDocUsage, CommDocUserAction} from 'app/common/CommTypes';
import {CommDocChatter, CommDocUsage, CommDocUserAction} from 'app/common/CommTypes';
import {delay} from 'app/common/delay';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {isSchemaAction, UserAction} from 'app/common/DocActions';
@ -452,6 +453,8 @@ export class GristDoc extends DisposableWithEvents {
this.listenTo(app.comm, 'docUsage', this.onDocUsageMessage);
this.listenTo(app.comm, 'docChatter', this.onDocChatter);
this.autoDispose(DocConfigTab.create({gristDoc: this}));
this.rightPanelTool = Computed.create(this, (use) => this._getToolContent(use(this._rightPanelTool)));
@ -565,6 +568,7 @@ export class GristDoc extends DisposableWithEvents {
content === 'acl' ? dom.create(AccessRules, this) :
content === 'data' ? dom.create(RawDataPage, this) :
content === 'settings' ? dom.create(DocSettingsPage, this) :
content === 'webhook' ? dom.create(WebhookPage, this) :
content === 'GristDocTour' ? null :
(typeof content === 'object') ? dom.create(owner => {
// In case user changes a page, close the popup.
@ -706,6 +710,10 @@ export class GristDoc extends DisposableWithEvents {
}
}
public getUndoStack() {
return this._undoStack;
}
/**
* Process usage and product received from the server by updating their respective
* observables.
@ -719,6 +727,13 @@ export class GristDoc extends DisposableWithEvents {
});
}
public onDocChatter(message: CommDocChatter) {
if (!this.docComm.isActionFromThisDoc(message)) { return; }
if (message.data.webhooks) {
this.trigger('webhooks', message.data.webhooks);
}
}
public getTableModel(tableId: string): DataTableModel {
return this.docModel.dataTables[tableId];
}
@ -1440,6 +1455,11 @@ export class GristDoc extends DisposableWithEvents {
// This is raw data view
await urlState().pushUrl({docPage: 'data'});
this.viewModel.activeSectionId(sectionId);
} else if (section.isVirtual.peek()) {
// this is a virtual table, and therefore a webhook page (that is the only
// place virtual tables are used so far)
await urlState().pushUrl({docPage: 'webhook'});
this.viewModel.activeSectionId(sectionId);
} else {
const view: ViewRec = section.view.peek();
await this.openDocPage(view.getRowId());

@ -94,9 +94,10 @@ RecordLayout.prototype.resizeCallback = function() {
};
RecordLayout.prototype.getField = function(fieldRowId) {
// If fieldRowId is a string, then it's actually "colRef:label:value" placeholder that we use
// when adding a new field. If so, return a special object with the fields available.
if (typeof fieldRowId === 'string') {
// If fieldRowId is a string which includes ":", then it's actually "colRef:label:value"
// placeholder that we use when adding a new field. If so, return a special object with the fields
// available. Note that virtual tables also produces string fieldRowId but they have no ":".
if (typeof fieldRowId === 'string' && fieldRowId.includes(':')) {
var parts = gutil.maxsplit(fieldRowId, ":", 2);
return {
isNewField: true, // To make it easy to distinguish from a ViewField MetaRowModel

@ -9,6 +9,9 @@ import sortBy = require('lodash/sortBy');
export interface ActionGroupWithCursorPos extends MinimalActionGroup {
cursorPos?: CursorPos;
// For operations not done by the server, we supply a function to
// handle them.
op?: (ag: MinimalActionGroup, isUndo: boolean) => Promise<void>;
}
// Provides observables indicating disabled state for undo/redo.
@ -28,7 +31,7 @@ export class UndoStack extends dispose.Disposable {
private _gristDoc: GristDoc;
private _stack: ActionGroupWithCursorPos[];
private _pointer: number;
private _linkMap: Map<number, MinimalActionGroup[]>;
private _linkMap: Map<number, ActionGroupWithCursorPos[]>;
// Chain of promises which send undo actions to the server. This delays the execution of the
// next action until the current one has been received and moved the pointer index.
@ -119,11 +122,17 @@ export class UndoStack extends dispose.Disposable {
// responsive, then again when the action is done. The second jump matters more for most
// changes, but the first is the important one when Undoing an AddRecord.
this._gristDoc.moveToCursorPos(ag.cursorPos, ag).catch(() => { /* do nothing */ });
await this._gristDoc.docComm.applyUserActionsById(
actionGroups.map(a => a.actionNum),
actionGroups.map(a => a.actionHash),
isUndo,
{ otherId: ag.actionNum });
if (actionGroups.length === 1 && actionGroups[0].op) {
// this is an internal operation, rather than one done by the server,
// so we can't ask the server to undo it.
await actionGroups[0].op(actionGroups[0], isUndo);
} else {
await this._gristDoc.docComm.applyUserActionsById(
actionGroups.map(a => a.actionNum),
actionGroups.map(a => a.actionHash),
isUndo,
{ otherId: ag.actionNum });
}
this._gristDoc.moveToCursorPos(ag.cursorPos, ag).catch(() => { /* do nothing */ });
} catch (err) {
err.message = `Failed to apply ${isUndo ? 'undo' : 'redo'} action: ${err.message}`;
@ -134,7 +143,7 @@ export class UndoStack extends dispose.Disposable {
/**
* Find all actionGroups in the bundle that starts with the given action group.
*/
private _findActionBundle(ag: MinimalActionGroup) {
private _findActionBundle(ag: ActionGroupWithCursorPos) {
const prevNums = new Set();
const actionGroups = [];
const queue = [ag];

@ -32,6 +32,8 @@ export class DocData extends BaseDocData {
private _lastActionNum: number|null = null; // ActionNum of the last action in the current bundle, or null.
private _bundleSender: BundleSender;
private _virtualTablesFunc: Map<string, Constructor<TableData>>;
/**
* Constructor for DocData.
* @param {Object} docComm: A map of server methods available on this document.
@ -41,10 +43,12 @@ export class DocData extends BaseDocData {
constructor(public readonly docComm: DocComm, metaTableData: {[tableId: string]: TableDataAction}) {
super((tableId) => docComm.fetchTable(tableId), metaTableData);
this._bundleSender = new BundleSender(this.docComm);
this._virtualTablesFunc = new Map();
}
public createTableData(tableId: string, tableData: TableDataAction|null, colTypes: ColTypeMap): TableData {
return new TableData(this, tableId, tableData, colTypes);
const Cons = this._virtualTablesFunc?.get(tableId) || TableData;
return new Cons(this, tableId, tableData, colTypes);
}
// Version of inherited getTable() which returns the enhance TableData type.
@ -182,8 +186,16 @@ export class DocData extends BaseDocData {
return this.sendActions([action], optDesc).then((retValues) => retValues[0]);
}
public registerVirtualTable(tableId: string, Cons: typeof TableData) {
this._virtualTablesFunc.set(tableId, Cons);
}
// See documentation of sendActions().
private async _sendActionsImpl(actions: UserAction[], optDesc?: string): Promise<any[]> {
if (this._virtualTablesFunc?.has(actions[0]?.[1] as any)) {
// It would be easy to pass along actions, but we don't need this functionality yet.
throw new Error('_sendActionsImpl needs updating to direct actions to virtual tables');
}
const eventData = {actions};
this.sendActionsEmitter.emit(eventData);
const options = { desc: optDesc };
@ -282,3 +294,5 @@ export interface BundlingInfo<T = unknown> {
// Promise for when the bundle has been finalized.
completionPromise: Promise<void>;
}
type Constructor<T> = new (...args: any[]) => T;

@ -281,6 +281,7 @@ export class DocModel {
private _createVisibleTablesArray(): KoArray<TableRec> {
return createTablesArray(this.tables, r =>
!isHiddenTable(this.tables.tableData, r) &&
!isVirtualTable(this.tables.tableData, r) &&
(!isTutorialTable(this.tables.tableData, r) || this.showDocTutorialTable)
);
}
@ -326,3 +327,11 @@ function createTablesArray(
function isTutorialTable(tablesData: TableData, tableRef: UIRowId): boolean {
return tablesData.getValue(tableRef, 'tableId') === 'GristDocTutorial';
}
/**
* Check whether a table is virtual - currently that is done
* by having a string rowId rather than the expected integer.
*/
function isVirtualTable(tablesData: TableData, tableRef: UIRowId): boolean {
return typeof(tableRef) === 'string';
}

@ -0,0 +1,242 @@
import { reportError } from 'app/client/models/errors';
import { GristDoc } from 'app/client/components/GristDoc';
import { DocData } from 'app/client/models/DocData';
import { TableData } from 'app/client/models/TableData';
import { concatenateSummaries, summarizeStoredAndUndo } from 'app/common/ActionSummarizer';
import { TableDelta } from 'app/common/ActionSummary';
import { ProcessedAction } from 'app/common/AlternateActions';
import { DisposableWithEvents } from 'app/common/DisposableWithEvents';
import { DocAction, TableDataAction, UserAction } from 'app/common/DocActions';
import { DocDataCache } from 'app/common/DocDataCache';
import { ColTypeMap } from 'app/common/TableData';
import { RowRecord } from 'app/plugin/GristData';
import debounce = require('lodash/debounce');
/**
* An interface for use while editing a virtual table.
* This is the interface passed to beforeEdit and afterEdit callbacks.
* The getRecord method gives access to the record prior to the edit;
* the getRecordNew method gives access to (an internal copy of)
* the record after the edit.
* The same interface is passed in other places, in which case
* actions and delta are trivial.
*/
export interface IEdit {
gristDoc: GristDoc,
actions: ProcessedAction[], // UserActions plus corresponding DocActions (forward and undo).
delta: TableDelta, // A summary of the effect actions would have (or had).
/**
* Apply a set of actions. The result is from the store backing the
* virtual table. Will not trigger beforeEdit or afterEdit callbacks.
*/
patch(actions: UserAction[]): Promise<ProcessedAction[]>;
getRecord(rowId: number): RowRecord|undefined; // A record in the table.
getRecordNew(rowId: number): RowRecord|undefined; // A record in the table, after the edit.
getRowIds(): readonly number[]; // All rowIds in the table.
}
/**
* Interface with a back-end for a specific virtual table.
*/
export interface IExternalTable {
name: string; // the tableId of the virtual table (e.g. GristHidden_WebhookTable)
initialActions: DocAction[]; // actions to create the table.
fetchAll(): Promise<TableDataAction>; // get initial state of the table.
sync(editor: IEdit): Promise<void>; // incorporate external changes.
beforeEdit(editor: IEdit): Promise<void>; // called prior to committing a change.
afterEdit(editor: IEdit): Promise<void>; // called after committing a change.
afterAnySchemaChange(editor: IEdit): Promise<void>; // called after any schema change in the document.
}
// A counter to generate unique actionNums for undo actions.
let _counterForUndoActions: number = 1;
/**
* A flavor of TableData that is backed by external operations and local cache.
* This lets virtual tables "fit in" to a DocData instance.
*/
export class VirtualTableData extends TableData {
public gristDoc: GristDoc;
public ext: IExternalTable;
public cache: DocDataCache;
constructor(docData: DocData, tableId: string, tableData: TableDataAction|null, columnTypes: ColTypeMap) {
super(docData, tableId, tableData, columnTypes);
}
public setExt(_ext: IExternalTable) {
this.ext = _ext;
this.cache = new DocDataCache(this.ext.initialActions);
}
public get name() {
return this.ext.name;
}
public fetchData() {
return super.fetchData(async () => {
const data = await this.ext.fetchAll();
this.cache.docData.getTable(this.name)?.loadData(data);
return data;
});
}
public async sendTableActions(userActions: UserAction[]): Promise<any[]> {
const actions = await this._sendTableActionsCore(userActions,
{isUser: true});
await this.ext.afterEdit(this._editor(actions));
return actions.map(action => action.retValues);
}
public sync() {
return this.ext.sync(this._editor());
}
public async sendTableAction(action: UserAction): Promise<any> {
const retValues = await this.sendTableActions([action]);
return retValues[0];
}
public async schemaChange() {
await this.ext.afterAnySchemaChange(this._editor());
}
private _editor(actions: ProcessedAction[] = []): IEdit {
const summary = concatenateSummaries(
actions
.map(action => summarizeStoredAndUndo(action.stored, action.undo)));
const delta = summary.tableDeltas[this.name];
return {
actions,
delta,
gristDoc: this.gristDoc,
getRecord: rowId => this.getRecord(rowId),
getRecordNew: rowId => this.getRecord(rowId),
getRowIds: () => this.getRowIds(),
patch: userActions => this._sendTableActionsCore(userActions, {
hasTableIds: true,
isUser: false,
})
};
}
private async _sendTableActionsCore(userActions: UserAction[], options: {
isUser: boolean,
isUndo?: boolean,
hasTableIds?: boolean,
actionNum?: any,
}): Promise<ProcessedAction[]> {
const {isUndo, isUser, hasTableIds} = options;
if (!hasTableIds) {
userActions.forEach((action) => action.splice(1, 0, this.tableId));
}
const actions = await this.cache.sendTableActions(userActions);
if (isUser) {
const newTable = await this.cache.docData.requireTable(this.name);
try {
await this.ext.beforeEdit({
...this._editor(actions),
getRecordNew: rowId => newTable.getRecord(rowId),
});
} catch (e) {
actions.reverse();
for (const action of actions) {
await this.cache.sendTableActions(action.undo);
}
throw e;
}
}
for (const action of actions) {
for (const docAction of action.stored) {
this.docData.receiveAction(docAction);
this.cache.docData.receiveAction(docAction);
if (isUser) {
const code = `ext-${this.name}-${_counterForUndoActions}`;
_counterForUndoActions++;
this.gristDoc.getUndoStack().pushAction({
actionNum: code,
actionHash: 'hash',
fromSelf: true,
otherId: options.actionNum || 0,
linkId: 0,
rowIdHint: 0,
isUndo,
action,
op: this._doUndo.bind(this),
} as any);
}
}
}
return actions;
}
private async _doUndo(actionGroup: {
action: ProcessedAction,
actionNum: number|string,
}, isUndo: boolean) {
await this._sendTableActionsCore(
isUndo ? actionGroup.action.undo : actionGroup.action.stored,
{
isUndo,
isUser: true,
actionNum: actionGroup.actionNum,
hasTableIds: true,
});
}
}
/**
* Everything needed to run a virtual table. Contains a tableData instance.
* Subscribes to schema changes. Offers a debouncing lazySync method that
* will attempt to synchronize the virtual table with the external source
* one second after last call (or at most 2 seconds after the first
* call).
*/
export class VirtualTable {
public lazySync = debounce(this.sync, 1000, {
maxWait: 2000,
trailing: true,
});
public tableData: VirtualTableData;
public constructor(private _owner: DisposableWithEvents,
_gristDoc: GristDoc,
_ext: IExternalTable) {
if (!_gristDoc.docModel.docData.getTable(_ext.name)) {
// register the virtual table
_gristDoc.docModel.docData.registerVirtualTable(_ext.name, VirtualTableData);
// then process initial actions
for (const action of _ext.initialActions) {
_gristDoc.docData.receiveAction(action);
}
// pass in gristDoc and external interface
this.tableData = _gristDoc.docModel.docData.getTable(_ext.name)! as VirtualTableData;
//this.tableData.docApi = this.docApi;
this.tableData.gristDoc = _gristDoc;
this.tableData.setExt(_ext);
// subscribe to schema changes
this.tableData.schemaChange().catch(e => reportError(e));
_owner.listenTo(_gristDoc, 'schemaUpdateAction', () => this.tableData.schemaChange());
} else {
this.tableData = _gristDoc.docModel.docData.getTable(_ext.name)! as VirtualTableData;
}
// debounce is typed as returning a promise, but doesn't appear to actually do so?
Promise.resolve(this.lazySync()).catch(e => reportError(e));
}
public async sync() {
if (this._owner.isDisposed()) {
return;
}
await this.tableData.sync();
}
}

@ -56,6 +56,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
// true if this record is its table's rawViewSection, i.e. a 'raw data view'
// in which case the UI prevents various things like hiding columns or changing the widget type.
isRaw: ko.Computed<boolean>;
isVirtual: ko.Computed<boolean>;
isCollapsed: ko.Computed<boolean>;
borderWidthPx: ko.Computed<string>;
@ -367,6 +368,8 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
// in which case the UI prevents various things like hiding columns or changing the widget type.
this.isRaw = this.autoDispose(ko.pureComputed(() => this.table().rawViewSectionRef() === this.getRowId()));
this.isVirtual = this.autoDispose(ko.pureComputed(() => typeof this.id() === 'string'));
this.borderWidthPx = ko.pureComputed(() => this.borderWidth() + 'px');
this.layoutSpecObj = modelUtil.jsonObservable(this.layoutSpec);

@ -8,9 +8,11 @@ import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect';
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
import {makeT} from 'app/client/lib/localization';
import {reportError} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {docListHeader} from 'app/client/ui/DocMenuCss';
import {showTransientTooltip} from 'app/client/ui/tooltips';
import {primaryButtonLink} from 'app/client/ui2018/buttons';
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {select} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
@ -83,6 +85,8 @@ export class DocSettingsPage extends Disposable {
await copyToClipboard(docPageModel.currentDocId.get()!);
}),
)),
cssHeader(t('Webhooks'), cssBeta('Beta')),
cssDataRow(primaryButtonLink(t('Manage Webhooks'), urlState().setLinkUrl({docPage: 'webhook'}))),
);
}
@ -169,6 +173,13 @@ const cssDataRow = styled('div', `
width: 360px;
`);
const cssBeta = styled('sup', `
text-transform: uppercase;
color: ${theme.text};
font-size: ${vars.smallFontSize};
margin-left: 8px;
`);
// Check which engines can be selected in the UI, if any.
export function getSupportedEngineChoices(): EngineCode[] {
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};

@ -0,0 +1,427 @@
import { GristDoc } from 'app/client/components/GristDoc';
import { ViewSectionHelper } from 'app/client/components/ViewLayout';
import { makeT } from 'app/client/lib/localization';
import { reportMessage, reportSuccess } from 'app/client/models/errors';
import { IEdit, IExternalTable, VirtualTable } from 'app/client/models/VirtualTable';
import { docListHeader } from 'app/client/ui/DocMenuCss';
import { bigPrimaryButton } from 'app/client/ui2018/buttons';
import { mediaSmall, testId } from 'app/client/ui2018/cssVars';
import { ApiError } from 'app/common/ApiError';
import { DisposableWithEvents } from 'app/common/DisposableWithEvents';
import { DocAction, getColIdsFromDocAction, getColValues,
isDataAction, TableDataAction, UserAction } from 'app/common/DocActions';
import { WebhookSummary } from 'app/common/Triggers';
import { DocAPI } from 'app/common/UserAPI';
import { GristObjCode, RowRecord } from 'app/plugin/GristData';
import { dom, styled } from 'grainjs';
import omit = require('lodash/omit');
import pick = require('lodash/pick');
import range = require('lodash/range');
import without = require('lodash/without');
const t = makeT('WebhookPage');
/**
* A list of columns for a virtual table about webhooks.
* The ids need to be strings.
*/
const WEBHOOK_COLUMNS = [
{
id: 'vt_webhook_fc1',
colId: 'tableId',
type: 'Choice',
label: 'Table',
// widgetOptions are configured later, since the choices depend
// on the user tables in the document.
},
{
id: 'vt_webhook_fc2',
colId: 'url',
type: 'Text',
label: 'URL',
},
{
id: 'vt_webhook_fc3',
colId: 'eventTypes',
type: 'ChoiceList',
label: 'Event Types',
widgetOptions: JSON.stringify({
widget: 'TextBox',
alignment: 'left',
choices: ['add', 'update'],
choiceOptions: {},
}),
},
{
id: 'vt_webhook_fc4',
colId: 'enabled',
type: 'Bool',
label: 'Enabled',
widgetOptions: JSON.stringify({
widget: 'Switch',
}),
},
{
id: 'vt_webhook_fc5',
colId: 'isReadyColumn',
type: 'Text',
label: 'Ready Column',
},
{
id: 'vt_webhook_fc6',
colId: 'webhookId',
type: 'Text',
label: 'Webhook Id',
},
{
id: 'vt_webhook_fc7',
colId: 'name',
type: 'Text',
label: 'Name',
},
{
id: 'vt_webhook_fc8',
colId: 'memo',
type: 'Text',
label: 'Memo',
},
{
id: 'vt_webhook_fc9',
colId: 'status',
type: 'Text',
label: 'Status',
},
] as const;
/**
* Layout of fields in a view, with a specific ordering.
*/
const WEBHOOK_VIEW_FIELDS: Array<(typeof WEBHOOK_COLUMNS)[number]['colId']> = [
'name', 'memo',
'eventTypes', 'url',
'tableId', 'isReadyColumn',
'webhookId', 'enabled',
'status'
];
/**
*
* Change webhooks based on a virtual table.
*
* TODO: error handling is not rock-solid. If a set of actions are
* applied all together, and one fails, then state between UI and
* back-end may end up being inconsistent. One option would be just to
* resync in the case of an error. In practice, the way the virtual
* table is used in a card list, it would be hard to tickle this case
* right now, so I'm not going to worry about it.
*
*/
class WebhookExternalTable implements IExternalTable {
public name = 'GristHidden_WebhookTable';
public initialActions = _prepareWebhookInitialActions(this.name);
public saveableFields = [
'tableId', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn',
];
public constructor(private _docApi: DocAPI) {}
public async fetchAll(): Promise<TableDataAction> {
const webhooks = await this._docApi.getWebhooks();
const indices = range(webhooks.length);
return ['TableData', this.name, indices.map(i => i + 1),
getColValues(indices.map(rowId => _mapWebhookValues(webhooks[rowId])))];
}
public async beforeEdit(editor: IEdit) {
const results = editor.actions;
for (const r of results) {
for (const d of r.stored) {
if (!isDataAction(d)) { continue; }
const colIds = new Set(getColIdsFromDocAction(d) || []);
if (colIds.has('webhookId') || colIds.has('status')) {
throw new Error(`Sorry, not all fields can be edited.`);
}
}
}
const delta = editor.delta;
for (const recId of delta.removeRows) {
const rec = editor.getRecord(recId);
if (!rec) { continue; }
await this._removeWebhook(rec);
reportMessage(`Removed webhook.`);
}
const updates = new Set(delta.updateRows);
const t2 = editor;
for (const recId of updates) {
const rec = t2.getRecordNew(recId);
if (rec?.webhookId) {
await this._updateWebhook(String(rec?.webhookId), rec);
}
}
}
public async afterEdit(editor: IEdit) {
const {delta} = editor;
const updates = new Set(delta.updateRows);
const addsAndUpdates = new Set([...delta.addRows, ...delta.updateRows]);
for (const recId of addsAndUpdates) {
const rec = editor.getRecord(recId);
if (!rec) { continue; }
const notes: string[] = [];
const values: Record<string, any> = {};
if (!rec.webhookId) {
try {
const webhookId = await this._addWebhook(rec);
values.webhookId = webhookId;
notes.push("Added");
} catch (e) {
notes.push("Incomplete" + ' | ' + this._getErrorString(e).replace(/^Error: /, '').replace('\n', ' | '));
}
} else {
notes.push("Updated");
}
if (!values.status) {
values.status = notes.join('\n');
}
if (!updates.has(recId)) {
// 'enabled' needs an initial value, otherwise it is unsettable
values.enabled = false;
}
await editor.patch([
['UpdateRecord', this.name, recId, values],
]);
}
}
public async sync(editor: IEdit): Promise<void> {
// Map from external webhookId to local arbitrary rowId.
const rowMap = new Map(editor.getRowIds().map(rowId => [editor.getRecord(rowId)!.webhookId, rowId]));
// Provisional list of rows to remove (we'll be trimming this down
// as we go).
const toRemove = new Set(editor.getRowIds());
// Synchronization is done by applying a collected list of actions.
const actions: UserAction[] = [];
// Prepare to add or update webhook listings stored locally. Uses
// brute force, on the assumption that there won't be many
// webhooks, or that "updating" something that hasn't actually
// changed is not disruptive.
const webhooks = await this._docApi.getWebhooks();
for (const webhook of webhooks) {
const values = _mapWebhookValues(webhook);
const rowId = rowMap.get(webhook.id);
if (rowId) {
toRemove.delete(rowId);
actions.push(
['UpdateRecord', this.name, rowId, values]
);
} else {
actions.push(
['AddRecord', this.name, null, values]
);
}
}
// Prepare to remove webhook rows that no longer correspond to something that
// exists externally.
for (const rowId of toRemove) {
if (editor.getRecord(rowId)?.webhookId) {
actions.push(['RemoveRecord', this.name, rowId]);
}
}
// Apply the changes.
await editor.patch(actions);
}
public async afterAnySchemaChange(editor: IEdit) {
// Configure the table picker, since the set of tables may have changed.
// TODO: should do something about the ready column picker. Right now,
// Grist doesn't have a good way to handle contingent choices.
const choices = editor.gristDoc.docModel.visibleTables.all().map(tableRec => tableRec.tableId());
editor.gristDoc.docData.receiveAction([
'UpdateRecord', '_grist_Tables_column', 'vt_webhook_fc1' as any, {
widgetOptions: JSON.stringify({
widget: 'TextBox',
alignment: 'left',
choices,
})
}]);
}
private _getErrorString(e: ApiError): string {
return e.details?.userError || e.message;
}
private async _addWebhook(rec: RowRecord) {
const fields = this._prepareFields(rec);
// Leave enabled at default, meaning it will enable on successful
// creation. It seems likely we'd get support requests asking why
// webhooks are not working otherwise.
const {webhookId} = await this._docApi.addWebhook(omit(fields, 'enabled'));
return webhookId;
}
private async _updateWebhook(id: string, rec: RowRecord) {
const fields = this._prepareFields(rec);
if (Object.keys(fields).length) {
await this._docApi.updateWebhook({id, fields});
}
}
private async _removeWebhook(rec: RowRecord) {
if (rec.webhookId) {
await this._docApi.removeWebhook(String(rec.webhookId), String(rec.tableId));
}
}
/**
* Perform some transformations for sending fields to api:
* - (1) removes all non saveble props and
* - (2) removes the leading 'L' from eventTypes.
*/
private _prepareFields(fields: any) {
fields = pick(fields, ...this.saveableFields);
if (fields.eventTypes) {
fields.eventTypes = without(fields.eventTypes, 'L');
}
return fields;
}
}
/**
* Visualize webhooks. There's a button to clear the queue, and
* a card list of webhooks.
*/
export class WebhookPage extends DisposableWithEvents {
public docApi = this.gristDoc.docPageModel.appModel.api.getDocAPI(this.gristDoc.docId());
public sharedTable: VirtualTable;
constructor(public gristDoc: GristDoc) {
super();
const table = new VirtualTable(this, gristDoc, new WebhookExternalTable(this.docApi));
this.listenTo(gristDoc, 'webhooks', async () => {
await table.lazySync();
});
}
public buildDom() {
const viewSectionModel = this.gristDoc.docModel.viewSections.getRowModel('vt_webhook_fs1' as any);
ViewSectionHelper.create(this, this.gristDoc, viewSectionModel);
return cssContainer(
cssHeader(t('Webhook Settings')),
cssControlRow(
bigPrimaryButton(t("Clear Queue"),
dom.on('click', () => this.reset()),
testId('webhook-reset'),
),
),
// active_section here is a bit of a hack, to allow tests to run
// more easily.
dom('div.active_section.view_data_pane_container.flexvbox', viewSectionModel.viewInstance()!.viewPane),
);
}
public async reset() {
await this.docApi.flushWebhooks();
reportSuccess('Cleared webhook queue.');
}
}
const cssHeader = styled(docListHeader, `
margin-bottom: 0;
&:not(:first-of-type) {
margin-top: 40px;
}
`);
const cssControlRow = styled('div', `
flex: none;
margin-bottom: 16px;
margin-top: 16px;
display: flex;
gap: 16px;
`);
const cssContainer = styled('div', `
overflow-y: auto;
position: relative;
height: 100%;
padding: 32px 64px 24px 64px;
display: flex;
flex-direction: column;
@media ${mediaSmall} {
& {
padding: 32px 24px 24px 24px;
}
}
`);
/**
* Actions needed to create the virtual table about webhooks, and a
* view for it. There are some "any" casts to place string ids where
* numbers are expected.
*/
function _prepareWebhookInitialActions(tableId: string): DocAction[] {
return [[
// Add the virtual table.
'AddTable', tableId,
WEBHOOK_COLUMNS.map(col => ({
isFormula: true,
type: 'Any',
formula: '',
id: col.colId
}))
], [
// Add an entry for the virtual table.
'AddRecord', '_grist_Tables', 'vt_webhook_ft1' as any, { tableId, primaryViewId: 0 },
], [
// Add entries for the columns of the virtual table.
'BulkAddRecord', '_grist_Tables_column',
WEBHOOK_COLUMNS.map(col => col.id) as any, getColValues(WEBHOOK_COLUMNS.map(rec =>
Object.assign({
isFormula: false,
formula: '',
widgetOptions: '',
parentId: 'vt_webhook_ft1' as any,
}, omit(rec, ['id']) as any))),
], [
// Add a view section.
'AddRecord', '_grist_Views_section', 'vt_webhook_fs1' as any,
{ tableRef: 'vt_webhook_ft1', parentKey: 'detail', title: '', borderWidth: 1, defaultWidth: 100, theme: 'blocks' }
], [
// List the fields shown in the view section.
'BulkAddRecord', '_grist_Views_section_field', WEBHOOK_VIEW_FIELDS.map((_, i) => `vt_webhook_ff${i+1}`) as any, {
colRef: WEBHOOK_VIEW_FIELDS.map(colId => WEBHOOK_COLUMNS.find(r => r.colId === colId)!.id),
parentId: WEBHOOK_VIEW_FIELDS.map(() => 'vt_webhook_fs1'),
parentPos: WEBHOOK_VIEW_FIELDS.map((_, i) => i),
}
]];
}
/**
* Map a webhook summary to a webhook table raw record. The main
* difference is that `eventTypes` is tweaked to be in a cell format,
* and `status` is converted to a string.
*/
function _mapWebhookValues(webhookSummary: WebhookSummary): Partial<WebhookSchemaType> {
const fields = webhookSummary.fields;
const {eventTypes} = fields;
return {
...fields,
webhookId: webhookSummary.id,
status: JSON.stringify(webhookSummary.usage),
eventTypes: [GristObjCode.List, ...eventTypes],
};
}
type WebhookSchemaType = {
[prop in keyof WebhookSummary['fields']]: WebhookSummary['fields'][prop]
} & {
eventTypes: [GristObjCode, ...unknown[]];
status: string;
webhookId: string;
}

@ -166,12 +166,17 @@ class ActionSummarizer {
* that will be suitable for composition.
*/
export function summarizeAction(body: LocalActionBundle, options?: ActionSummaryOptions): ActionSummary {
return summarizeStoredAndUndo(getEnvContent(body.stored), body.undo, options);
}
export function summarizeStoredAndUndo(stored: DocAction[], undo: DocAction[],
options?: ActionSummaryOptions): ActionSummary {
const summarizer = new ActionSummarizer(options);
const summary = createEmptyActionSummary();
for (const act of getEnvContent(body.stored)) {
for (const act of stored) {
summarizer.addForwardAction(summary, act);
}
for (const act of Array.from(body.undo).reverse()) {
for (const act of Array.from(undo).reverse()) {
summarizer.addReverseAction(summary, act);
}
// Name tables consistently, by their ultimate name, now we know it.

@ -0,0 +1,200 @@
import { BulkColValues, ColValues, DocAction, isSchemaAction,
TableDataAction, UserAction } from 'app/common/DocActions';
const ACTION_TYPES = new Set([
'AddRecord', 'BulkAddRecord', 'UpdateRecord', 'BulkUpdateRecord',
'RemoveRecord', 'BulkRemoveRecord'
]);
/**
* The result of processing a UserAction.
*/
export interface ProcessedAction {
stored: DocAction[];
undo: DocAction[];
retValues: any;
}
/**
* A minimal interface for interpreting UserActions in the context of
* some current state. We need to know the next free rowId for each
* table, and also the current state of cells. This interface was
* abstracted from the initial implementation of on-demand tables.
*/
export interface AlternateStorage {
getNextRowId(tableId: string): Promise<number>;
fetchActionData(tableId: string, rowIds: number[],
colIds?: string[]): Promise<TableDataAction>;
}
/**
* Handle converting UserActions to DocActions for tables stored
* in some way that is not handled by the regular data engine.
*/
export class AlternateActions {
constructor(private _storage: AlternateStorage) {}
/**
* This may be overridden to allow mixing two different storage mechanisms.
* The implementation of on-demand tables does this.
*/
public usesAlternateStorage(tableId: string): boolean {
return true;
}
/**
* Convert a UserAction into stored and undo DocActions as well as return values.
*/
public processUserAction(action: UserAction): Promise<ProcessedAction> {
const a = action.map(item => item as any);
switch (a[0]) {
case "ApplyUndoActions": return this._doApplyUndoActions(a[1]);
case "AddRecord": return this._doAddRecord (a[1], a[2], a[3]);
case "BulkAddRecord": return this._doBulkAddRecord (a[1], a[2], a[3]);
case "UpdateRecord": return this._doUpdateRecord (a[1], a[2], a[3]);
case "BulkUpdateRecord": return this._doBulkUpdateRecord(a[1], a[2], a[3]);
case "RemoveRecord": return this._doRemoveRecord (a[1], a[2]);
case "BulkRemoveRecord": return this._doBulkRemoveRecord(a[1], a[2]);
default: throw new Error(`Received unknown action ${action[0]}`);
}
}
/**
* Splits an array of UserActions into two separate arrays of normal and onDemand actions.
*/
public splitByStorage(actions: UserAction[]): [UserAction[], UserAction[]] {
const normal: UserAction[] = [];
const onDemand: UserAction[] = [];
actions.forEach(a => {
// Check that the actionType can be applied without the sandbox and also that the action
// is on a data table.
const isOnDemandAction = ACTION_TYPES.has(a[0] as string);
const isDataTableAction = typeof a[1] === 'string' && !a[1].startsWith('_grist_');
if (a[0] === 'ApplyUndoActions') {
// Split actions inside the undo action array.
const [undoNormal, undoOnDemand] = this.splitByStorage(a[1] as UserAction[]);
if (undoNormal.length > 0) {
normal.push(['ApplyUndoActions', undoNormal]);
}
if (undoOnDemand.length > 0) {
onDemand.push(['ApplyUndoActions', undoOnDemand]);
}
} else if (isDataTableAction && isOnDemandAction && this.usesAlternateStorage(a[1] as string)) {
// Check whether the tableId belongs to an onDemand table.
onDemand.push(a);
} else {
normal.push(a);
}
});
return [normal, onDemand];
}
/**
* Check if an action represents a schema change on an onDemand table.
*/
public isSchemaAction(docAction: DocAction): boolean {
return isSchemaAction(docAction) && this.usesAlternateStorage(docAction[1]);
}
private async _doApplyUndoActions(actions: DocAction[]) {
const undo: DocAction[] = [];
for (const a of actions) {
const converted = await this.processUserAction(a);
undo.concat(converted.undo);
}
return {
stored: actions,
undo,
retValues: null
};
}
private async _doAddRecord(
tableId: string,
rowId: number|null,
colValues: ColValues
): Promise<ProcessedAction> {
if (rowId === null) {
rowId = await this._storage.getNextRowId(tableId);
}
// Set the manualSort to be the same as the rowId. This forces new rows to always be added
// at the end of the table.
colValues.manualSort = rowId;
return {
stored: [['AddRecord', tableId, rowId, colValues]],
undo: [['RemoveRecord', tableId, rowId]],
retValues: rowId
};
}
private async _doBulkAddRecord(
tableId: string,
rowIds: Array<number|null>,
colValues: BulkColValues
): Promise<ProcessedAction> {
// When unset, we will set the rowId values to count up from the greatest
// values already in the table.
if (rowIds[0] === null) {
const nextRowId = await this._storage.getNextRowId(tableId);
for (let i = 0; i < rowIds.length; i++) {
rowIds[i] = nextRowId + i;
}
}
// Set the manualSort values to be the same as the rowIds. This forces new rows to always be
// added at the end of the table.
colValues.manualSort = rowIds;
return {
stored: [['BulkAddRecord', tableId, rowIds as number[], colValues]],
undo: [['BulkRemoveRecord', tableId, rowIds as number[]]],
retValues: rowIds
};
}
private async _doUpdateRecord(
tableId: string,
rowId: number,
colValues: ColValues
): Promise<ProcessedAction> {
const [, , oldRowIds, oldColValues] =
await this._storage.fetchActionData(tableId, [rowId], Object.keys(colValues));
return {
stored: [['UpdateRecord', tableId, rowId, colValues]],
undo: [['BulkUpdateRecord', tableId, oldRowIds, oldColValues]],
retValues: null
};
}
private async _doBulkUpdateRecord(
tableId: string,
rowIds: number[],
colValues: BulkColValues
): Promise<ProcessedAction> {
const [, , oldRowIds, oldColValues] =
await this._storage.fetchActionData(tableId, rowIds, Object.keys(colValues));
return {
stored: [['BulkUpdateRecord', tableId, rowIds, colValues]],
undo: [['BulkUpdateRecord', tableId, oldRowIds, oldColValues]],
retValues: null
};
}
private async _doRemoveRecord(tableId: string, rowId: number): Promise<ProcessedAction> {
const [, , oldRowIds, oldColValues] = await this._storage.fetchActionData(tableId, [rowId]);
return {
stored: [['RemoveRecord', tableId, rowId]],
undo: [['BulkAddRecord', tableId, oldRowIds, oldColValues]],
retValues: null
};
}
private async _doBulkRemoveRecord(tableId: string, rowIds: number[]): Promise<ProcessedAction> {
const [, , oldRowIds, oldColValues] = await this._storage.fetchActionData(tableId, rowIds);
return {
stored: [['BulkRemoveRecord', tableId, rowIds]],
undo: [['BulkAddRecord', tableId, oldRowIds, oldColValues]],
retValues: null
};
}
}

@ -138,8 +138,13 @@ function throwApiError(url: string, resp: Response | AxiosResponse, body: any) {
// If the response includes details, include them into the ApiError we construct. Include
// also the error message from the server as details.userError. It's used by the Notifier.
if (!body) { body = {}; }
const details: ApiErrorDetails = body.details && typeof body.details === 'object' ? body.details : {};
if (body.error) {
const details: ApiErrorDetails = body.details && typeof body.details === 'object' ? body.details :
{errorDetails: body.details};
// If a userError is already specified, do not overwrite it.
// (The error handling here is quite confusing, would it not be better
// to just unserialize an ApiError into the form it would have had on
// the server?)
if (body.error && !details.userError) {
details.userError = body.error;
}
if (body.memos) {

@ -5,8 +5,9 @@ import {Product} from 'app/common/Features';
import {StringUnion} from 'app/common/StringUnion';
import {UserProfile} from 'app/common/LoginSessionAPI';
export const ValidEvent = StringUnion('docListAction', 'docUserAction', 'docShutdown', 'docError',
'docUsage', 'clientConnect');
export const ValidEvent = StringUnion(
'docListAction', 'docUserAction', 'docShutdown', 'docError',
'docUsage', 'docChatter', 'clientConnect');
export type ValidEvent = typeof ValidEvent.type;
@ -50,12 +51,12 @@ export interface CommMessageBase {
data?: unknown;
}
export type CommDocMessage = CommDocUserAction | CommDocUsage | CommDocShutdown | CommDocError;
export type CommDocMessage = CommDocUserAction | CommDocUsage | CommDocShutdown | CommDocError | CommDocChatter;
export type CommMessage = CommDocMessage | CommDocListAction | CommClientConnect;
export type CommResponseBase = CommResponse | CommResponseError | CommMessage;
export type CommDocEventType = CommDocMessage['type']
export type CommDocEventType = CommDocMessage['type'];
/**
* Event for a change to the document list.
@ -89,6 +90,20 @@ export interface CommDocUserAction extends CommMessageBase {
};
}
export interface CommDocChatter extends CommMessageBase {
type: 'docChatter';
docFD: number;
data: {
webhooks?: {
// If present, something happened related to webhooks.
// Currently, we give no details, leaving it to client
// to call back for details if it cares.
},
// This could also be a fine place to send updated info
// about other users of the document.
};
}
/**
* Event for a change to document usage. Sent to all clients that have this document open.
*/

@ -75,6 +75,15 @@ export class DocData extends ActionDispatcher {
return this._tables.get(tableId);
}
public async requireTable(tableId: string): Promise<TableData> {
await this.fetchTable(tableId);
const td = this._tables.get(tableId);
if (!td) {
throw new Error(`could not fetch table: ${tableId}`);
}
return td;
}
/**
* Like getTable, but the result knows about the types of its records
*/

@ -0,0 +1,51 @@
import { AlternateActions, AlternateStorage, ProcessedAction} from 'app/common/AlternateActions';
import { DocAction, UserAction } from 'app/common/DocActions';
import { DocData } from 'app/common/DocData';
import max from 'lodash/max';
/**
* An implementation of an in-memory storage that can handle UserActions,
* generating DocActions and retValues that work as for regular storage.
* It shares an implementation with on-demand tables.
*/
export class DocDataCache implements AlternateStorage {
public docData: DocData;
private _altActions: AlternateActions;
constructor(actions?: DocAction[]) {
this.docData = new DocData(
async (tableId) => {
throw new Error(`no ${tableId}`);
},
null,
);
this._altActions = new AlternateActions(this);
for (const action of actions || []) {
this.docData.receiveAction(action);
}
}
public async sendTableActions(actions: UserAction[]): Promise<ProcessedAction[]> {
const results: ProcessedAction[] = [];
for (const userAction of actions) {
const processedAction = await this._altActions.processUserAction(userAction);
results.push(processedAction);
for (const storedAction of processedAction.stored) {
this.docData.receiveAction(storedAction);
}
}
return results;
}
public async fetchActionData(tableId: string, rowIds: number[], colIds?: string[]) {
const table = await this.docData.requireTable(tableId);
return table.getTableDataAction(
rowIds,
colIds,
);
}
public async getNextRowId(tableId: string): Promise<number> {
const table = await this.docData.requireTable(tableId);
return (max(table.getRowIds()) || 0) + 1;
}
}

@ -254,23 +254,27 @@ export class TableData extends ActionDispatcher implements SkippableRows {
* Optionally takes a list of row ids to return data from. If a row id is
* not actually present in the table, a row of nulls will be returned for it.
*/
public getTableDataAction(desiredRowIds?: number[]): TableDataAction {
public getTableDataAction(desiredRowIds?: number[],
colIds?: string[]): TableDataAction {
colIds = colIds || this.getColIds();
const colIdSet = new Set<string>(colIds);
const rowIds = desiredRowIds || this.getRowIds();
let bulkColValues: {[colId: string]: CellValue[]};
const colArray = this._colArray.filter(({colId}) => colIdSet.has(colId));
if (desiredRowIds) {
const len = rowIds.length;
bulkColValues = {};
for (const colId of this.getColIds()) { bulkColValues[colId] = Array(len); }
for (const colId of colIds) { bulkColValues[colId] = Array(len); }
for (let i = 0; i < len; i++) {
const index = this._rowMap.get(rowIds[i]);
for (const {colId, values} of this._colArray) {
for (const {colId, values} of colArray) {
const value = (index === undefined) ? null : values[index];
bulkColValues[colId][i] = value;
}
}
} else {
bulkColValues = fromPairs(
this.getColIds()
colIds
.filter(colId => colId !== 'id')
.map(colId => [colId, this.getColValues(colId)! as CellValue[]]));
}

@ -10,13 +10,36 @@ export const WebhookFields = t.iface([], {
"tableId": "string",
"enabled": t.opt("boolean"),
"isReadyColumn": t.opt(t.union("string", "null")),
"name": t.opt("string"),
"memo": t.opt("string"),
});
export const WebhookBatchStatus = t.union(t.lit('success'), t.lit('failure'), t.lit('rejected'));
export const WebhookStatus = t.union(t.lit('idle'), t.lit('sending'), t.lit('retrying'), t.lit('postponed'), t.lit('error'), t.lit('invalid'));
export const WebhookSubscribe = t.iface([], {
"url": "string",
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
"enabled": t.opt("boolean"),
"isReadyColumn": t.opt(t.union("string", "null")),
"name": t.opt("string"),
"memo": t.opt("string"),
});
export const WebhookSummary = t.iface([], {
"id": "string",
"fields": t.iface([], {
"url": "string",
"unsubscribeKey": "string",
"eventTypes": t.array("string"),
"isReadyColumn": t.union("string", "null"),
"tableId": "string",
"enabled": "boolean",
"name": "string",
"memo": "string",
}),
"usage": t.union("WebhookUsage", "null"),
});
export const WebhookUpdate = t.iface([], {
@ -30,12 +53,39 @@ export const WebhookPatch = t.iface([], {
"tableId": t.opt("string"),
"enabled": t.opt("boolean"),
"isReadyColumn": t.opt(t.union("string", "null")),
"name": t.opt("string"),
"memo": t.opt("string"),
});
export const WebhookUsage = t.iface([], {
"numWaiting": "number",
"status": "WebhookStatus",
"updatedTime": t.opt(t.union("number", "null")),
"lastSuccessTime": t.opt(t.union("number", "null")),
"lastFailureTime": t.opt(t.union("number", "null")),
"lastErrorMessage": t.opt(t.union("string", "null")),
"lastHttpStatus": t.opt(t.union("number", "null")),
"lastEventBatch": t.opt(t.union("null", t.iface([], {
"size": "number",
"errorMessage": t.union("string", "null"),
"httpStatus": t.union("number", "null"),
"status": "WebhookBatchStatus",
"attempts": "number",
}))),
"numSuccess": t.opt(t.iface([], {
"pastHour": "number",
"past24Hours": "number",
})),
});
const exportedTypeSuite: t.ITypeSuite = {
WebhookFields,
WebhookBatchStatus,
WebhookStatus,
WebhookSubscribe,
WebhookSummary,
WebhookUpdate,
WebhookPatch,
WebhookUsage,
};
export default exportedTypeSuite;

@ -4,8 +4,15 @@ export interface WebhookFields {
tableId: string;
enabled?: boolean;
isReadyColumn?: string|null;
name?: string;
memo?: string;
}
// Union discriminated by type
export type WebhookBatchStatus = 'success'|'failure'|'rejected';
export type WebhookStatus = 'idle'|'sending'|'retrying'|'postponed'|'error'|'invalid';
// WebhookSubscribe should be `Omit<WebhookFields, 'tableId'>` (because subscribe endpoint read
// tableId from the url) but generics are not yet supported by ts-interface-builder
export interface WebhookSubscribe {
@ -13,6 +20,23 @@ export interface WebhookSubscribe {
eventTypes: Array<"add"|"update">;
enabled?: boolean;
isReadyColumn?: string|null;
name?: string;
memo?: string;
}
export interface WebhookSummary {
id: string;
fields: {
url: string;
unsubscribeKey: string;
eventTypes: string[];
isReadyColumn: string|null;
tableId: string;
enabled: boolean;
name: string;
memo: string;
},
usage: WebhookUsage|null,
}
// Describes fields to update a webhook
@ -29,4 +53,29 @@ export interface WebhookPatch {
tableId?: string;
enabled?: boolean;
isReadyColumn?: string|null;
name?: string;
memo?: string;
}
export interface WebhookUsage {
// As minimum we need number of waiting events and status (by default pending).
numWaiting: number,
status: WebhookStatus;
updatedTime?: number|null;
lastSuccessTime?: number|null;
lastFailureTime?: number|null;
lastErrorMessage?: string|null;
lastHttpStatus?: number|null;
lastEventBatch?: null | {
size: number;
errorMessage: string|null;
httpStatus: number|null;
status: WebhookBatchStatus;
attempts: number;
},
numSuccess?: {
pastHour: number;
past24Hours: number;
},
}

@ -14,7 +14,9 @@ import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs';
import * as roles from 'app/common/roles';
import {addCurrentOrgToPath} from 'app/common/urlUtils';
import {encodeQueryParams} from 'app/common/gutil';
import {WebhookUpdate} from 'app/common/Triggers';
import {WebhookFields, WebhookSubscribe, WebhookSummary, WebhookUpdate} from 'app/common/Triggers';
import omitBy from 'lodash/omitBy';
export type {FullUser, UserProfile};
@ -454,8 +456,12 @@ export interface DocAPI {
// Get users that are worth proposing to "View As" for access control purposes.
getUsersForViewAs(): Promise<PermissionDataWithExtraUsers>;
getWebhooks(): Promise<WebhookSummary[]>;
addWebhook(webhook: WebhookFields): Promise<{webhookId: string}>;
removeWebhook(webhookId: string, tableId: string): Promise<void>;
// Update webhook
updateWebhook(webhook: WebhookUpdate): Promise<void>;
flushWebhooks(): Promise<void>;
}
// Operations that are supported by a doc worker.
@ -905,6 +911,19 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
return this.requestJson(`${this._url}/usersForViewAs`);
}
public async getWebhooks(): Promise<WebhookSummary[]> {
return this.requestJson(`${this._url}/webhooks`);
}
public async addWebhook(webhook: WebhookSubscribe & {tableId: string}): Promise<{webhookId: string}> {
const {tableId} = webhook;
return this.requestJson(`${this._url}/tables/${tableId}/_subscribe`, {
method: 'POST',
body: JSON.stringify(
omitBy(webhook, (val, key) => key === 'tableId' || val === null)),
});
}
public async updateWebhook(webhook: WebhookUpdate): Promise<void> {
return this.requestJson(`${this._url}/webhooks/${webhook.id}`, {
method: 'PATCH',
@ -912,6 +931,21 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
});
}
public removeWebhook(webhookId: string, tableId: string) {
// unsubscribeKey is not required for owners
const unsubscribeKey = '';
return this.requestJson(`${this._url}/tables/${tableId}/_unsubscribe`, {
method: 'POST',
body: JSON.stringify({webhookId, unsubscribeKey}),
});
}
public async flushWebhooks(): Promise<void> {
await this.request(`${this._url}/webhooks/queue`, {
method: 'DELETE'
});
}
public async forceReload(): Promise<void> {
await this.request(`${this._url}/force-reload`, {
method: 'POST'

@ -10,7 +10,7 @@ import {Document} from 'app/common/UserAPI';
import clone = require('lodash/clone');
import pickBy = require('lodash/pickBy');
export const SpecialDocPage = StringUnion('code', 'acl', 'data', 'GristDocTour', 'settings');
export const SpecialDocPage = StringUnion('code', 'acl', 'data', 'GristDocTour', 'settings', 'webhook');
type SpecialDocPage = typeof SpecialDocPage.type;
export type IDocPage = number | SpecialDocPage;

@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
// tslint:disable:object-literal-key-quotes
export const SCHEMA_VERSION = 37;
export const SCHEMA_VERSION = 38;
export const schema = {
@ -160,6 +160,9 @@ export const schema = {
eventTypes : "ChoiceList",
isReadyColRef : "Ref:_grist_Tables_column",
actions : "Text",
label : "Text",
memo : "Text",
enabled : "Bool",
},
"_grist_ACLRules": {
@ -367,6 +370,9 @@ export interface SchemaTypes {
eventTypes: [GristObjCode.List, ...string[]]|null;
isReadyColRef: number;
actions: string;
label: string;
memo: string;
enabled: boolean;
};
"_grist_ACLRules": {

@ -19,7 +19,7 @@ import {createEmptyActionSummary} from 'app/common/ActionSummary';
import {getSelectionDesc, UserAction} from 'app/common/DocActions';
import {DocState} from 'app/common/UserAPI';
import toPairs = require('lodash/toPairs');
import {summarizeAction} from './ActionSummary';
import {summarizeAction} from 'app/common/ActionSummarizer';
export interface ActionGroupOptions {
// If set, inspect the action in detail in order to include a summary of

@ -1566,7 +1566,7 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
docSession: OptDocSession|null,
userActions: UserAction[]
): Promise<SandboxActionBundle> {
const [normalActions, onDemandActions] = this._onDemandActions.splitByOnDemand(userActions);
const [normalActions, onDemandActions] = this._onDemandActions.splitByStorage(userActions);
let sandboxActionBundle: SandboxActionBundle;
if (normalActions.length > 0) {
@ -1768,6 +1768,18 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
return this._triggers.summary();
}
/**
* Send a message to clients connected to the document that something
* webhook-related has happened (a change in configuration, or a
* delivery, or an error). There is room to give details in future,
* if that proves useful, but for now no details are needed.
*/
public async sendWebhookNotification() {
await this.docClients.broadcastDocMessage(null, 'docChatter', {
webhooks: {},
});
}
public logTelemetryEvent(
docSession: OptDocSession | null,
eventName: TelemetryEventName,

@ -28,7 +28,7 @@ import {
TableOperationsImpl,
TableOperationsPlatform
} from 'app/plugin/TableOperationsImpl';
import {concatenateSummaries, summarizeAction} from "app/server/lib/ActionSummary";
import {concatenateSummaries, summarizeAction} from "app/common/ActionSummarizer";
import {ActiveDoc, colIdToRef as colIdToReference, tableIdToRef} from "app/server/lib/ActiveDoc";
import {
assertAccess,
@ -117,18 +117,18 @@ const {
*/
function validate(checker: Checker): RequestHandler {
return (req, res, next) => {
validateCore(checker, req, req.body);
next();
};
}
function validateCore(checker: Checker, req: Request, body: any) {
try {
checker.check(req.body);
checker.check(body);
} catch(err) {
log.warn(`Error during api call to ${req.path}: Invalid payload: ${String(err)}`);
res.status(400).json({
error : "Invalid payload",
details: String(err)
}).end();
return;
throw new ApiError('Invalid payload', 400, {userError: String(err)});
}
next();
};
}
export class DocWorkerApi {
@ -237,6 +237,59 @@ export class DocWorkerApi {
activeDoc.fetchMetaTables(docSessionFromRequest(req)));
}
async function getWebhookSettings(activeDoc: ActiveDoc, req: RequestWithLogin, webhookId: string|null) {
const metaTables = await getMetaTables(activeDoc, req);
const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables");
const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined;
let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : undefined;
const {url, eventTypes, isReadyColumn, name} = req.body;
const tableId = req.params.tableId || req.body.tableId;
const fields: Partial<SchemaTypes['_grist_Triggers']> = {};
if (url && !isUrlAllowed(url)) {
throw new ApiError('Provided url is forbidden', 403);
}
if (eventTypes) {
if (!eventTypes.length) {
throw new ApiError(`eventTypes must be a non-empty array`, 400);
}
fields.eventTypes = [GristObjCode.List, ...eventTypes];
}
if (tableId !== undefined) {
fields.tableRef = tableIdToRef(metaTables, tableId);
currentTableId = tableId;
}
if (isReadyColumn !== undefined) {
// When isReadyColumn is defined let's explicitly change the ready column to the new col
// id, null or empty string being a special case that unsets it.
if (isReadyColumn !== null && isReadyColumn !== '') {
if (!currentTableId) {
throw new ApiError(`Cannot find column "${isReadyColumn}" because table is not known`, 404);
}
fields.isReadyColRef = colIdToReference(metaTables, currentTableId, isReadyColumn);
} else {
fields.isReadyColRef = 0;
}
} else if (tableId) {
// When isReadyColumn is undefined but tableId was changed, let's unset the ready column
fields.isReadyColRef = 0;
}
// assign other field properties
Object.assign(fields, _.pick(req.body, ['enabled', 'memo']));
if (name) {
fields.label = name;
}
return {
fields,
url,
trigger,
};
}
// Get the columns of the specified table in recordish format
this._app.get('/api/docs/:docId/tables/:tableId/columns', canView,
withDoc(async (activeDoc, req, res) => {
@ -358,9 +411,27 @@ export class DocWorkerApi {
// Adds records given in a record oriented format,
// returns in the same format as GET /records but without the fields object for now
this._app.post('/api/docs/:docId/tables/:tableId/records', canEdit, validate(RecordsPost),
// WARNING: The `req.body` object is modified in place.
this._app.post('/api/docs/:docId/tables/:tableId/records', canEdit,
withDoc(async (activeDoc, req, res) => {
const body = req.body as Types.RecordsPost;
let body = req.body;
if (isAffirmative(req.query.flat)) {
if (!body.records && Array.isArray(body)) {
for (const [i, rec] of body.entries()) {
if (!rec.fields) {
// If ids arrive in a loosely formatted flat payload,
// remove them since we cannot honor them. If not loosely
// formatted, throw an error later. TODO: would be useful
// to have a way to exclude or rename fields via query
// parameters.
if (rec.id) { delete rec.id; }
body[i] = {fields: rec};
}
}
body = {records: body};
}
}
validateCore(RecordsPost, req, body);
const ops = getTableOperations(req, activeDoc);
const records = await ops.create(body.records);
res.json({records});
@ -550,22 +621,15 @@ export class DocWorkerApi {
// Add a new webhook and trigger
this._app.post('/api/docs/:docId/tables/:tableId/_subscribe', isOwner, validate(WebhookSubscribe),
withDoc(async (activeDoc, req, res) => {
const {isReadyColumn, eventTypes, url} = req.body;
if (!eventTypes.length) {
const {fields, url} = await getWebhookSettings(activeDoc, req, null);
if (!fields.eventTypes?.length) {
throw new ApiError(`eventTypes must be a non-empty array`, 400);
}
if (!isUrlAllowed(url)) {
throw new ApiError('Provided url is forbidden', 403);
}
const tableId = req.params.tableId;
const metaTables = await getMetaTables(activeDoc, req);
const tableRef = tableIdToRef(metaTables, tableId);
let isReadyColRef = 0;
if (isReadyColumn) {
isReadyColRef = colIdToReference(metaTables, tableId, isReadyColumn);
if (!fields.tableRef) {
throw new ApiError(`tableId is required`, 400);
}
const unsubscribeKey = uuidv4();
@ -579,9 +643,8 @@ export class DocWorkerApi {
const sandboxRes = await handleSandboxError("_grist_Triggers", [], activeDoc.applyUserActions(
docSessionFromRequest(req),
[['AddRecord', "_grist_Triggers", null, {
eventTypes: [GristObjCode.List, ...eventTypes],
isReadyColRef,
tableRef,
enabled: true,
...fields,
actions: JSON.stringify([webhookAction])
}]]));
@ -596,6 +659,8 @@ export class DocWorkerApi {
// remove webhook
await this._dbManager.removeWebhook(webhookId, activeDoc.docName, '', false);
throw err;
} finally {
await activeDoc.sendWebhookNotification();
}
})
);
@ -622,56 +687,19 @@ export class DocWorkerApi {
docSessionFromRequest(req),
[['RemoveRecord', "_grist_Triggers", triggerRowId]]));
await activeDoc.sendWebhookNotification();
res.json({success: true});
})
);
// Update a webhoook
// Update a webhook
this._app.patch(
'/api/docs/:docId/webhooks/:webhookId', isOwner, validate(WebhookPatch), withDoc(async (activeDoc, req, res) => {
const docId = activeDoc.docName;
const webhookId = req.params.webhookId;
const metaTables = await getMetaTables(activeDoc, req);
const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables");
const trigger = activeDoc.triggers.getWebhookTriggerRecord(webhookId);
let currentTableId = tablesTable.getValue(trigger.tableRef, 'tableId')!;
const {url, eventTypes, isReadyColumn, tableId} = req.body;
const fields: Partial<SchemaTypes['_grist_Triggers']> = {};
if (url && !isUrlAllowed(url)) {
// TODO: remove redundancy with same validation in _subscribe endpoint
throw new ApiError('Provided url is forbidden', 403);
}
if (eventTypes) {
// TODO: remove redundancy with same validation in _subscribe endpoint
if (!eventTypes.length) {
throw new ApiError(`eventTypes must be a non-empty array`, 400);
}
fields.eventTypes = [GristObjCode.List, ...eventTypes];
}
if (tableId !== undefined) {
fields.tableRef = tableIdToRef(metaTables, tableId);
currentTableId = tableId;
}
if (isReadyColumn !== undefined) {
// When isReadyColumn is defined let's explicitly changes the ready column to the new col
// id, null being a special case that unsets it.
if (isReadyColumn !== null) {
fields.isReadyColRef = colIdToReference(metaTables, currentTableId, isReadyColumn);
} else {
fields.isReadyColRef = 0;
}
} else if (tableId) {
// When isReadyColumn is undefined but tableId was changed, let's implicitely unset the ready column
fields.isReadyColRef = 0;
}
// assign other fields properties
Object.assign(fields, _.pick(req.body, ['enabled']));
const {fields, trigger, url} = await getWebhookSettings(activeDoc, req, webhookId);
const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId).id;
@ -694,6 +722,8 @@ export class DocWorkerApi {
}
});
await activeDoc.sendWebhookNotification();
res.json({success: true});
})
);
@ -702,6 +732,7 @@ export class DocWorkerApi {
this._app.delete('/api/docs/:docId/webhooks/queue', isOwner,
withDoc(async (activeDoc, req, res) => {
await activeDoc.clearWebhookQueue();
await activeDoc.sendWebhookNotification();
res.json({success: true});
})
);

@ -1,32 +1,23 @@
import {BulkColValues, ColValues, DocAction, isSchemaAction, TableDataAction, UserAction} from 'app/common/DocActions';
import {AlternateActions, AlternateStorage} from 'app/common/AlternateActions';
import {DocData} from 'app/common/DocData';
import {TableData} from 'app/common/TableData';
import {IndexColumns} from 'app/server/lib/DocStorage';
const ACTION_TYPES = new Set(['AddRecord', 'BulkAddRecord', 'UpdateRecord', 'BulkUpdateRecord',
'RemoveRecord', 'BulkRemoveRecord']);
export interface ProcessedAction {
stored: DocAction[];
undo: DocAction[];
retValues: any;
}
export interface OnDemandStorage {
getNextRowId(tableId: string): Promise<number>;
fetchActionData(tableId: string, rowIds: number[], colIds?: string[]): Promise<TableDataAction>;
}
export type {ProcessedAction} from 'app/common/AlternateActions';
export type OnDemandStorage = AlternateStorage;
/**
* Handle converting UserActions to DocActions for onDemand tables.
*/
export class OnDemandActions {
export class OnDemandActions extends AlternateActions {
private _tablesMeta: TableData = this._docData.getMetaTable('_grist_Tables');
private _columnsMeta: TableData = this._docData.getMetaTable('_grist_Tables_column');
constructor(private _storage: OnDemandStorage, private _docData: DocData,
private _forceOnDemand: boolean = false) {}
constructor(_storage: OnDemandStorage, private _docData: DocData,
private _forceOnDemand: boolean = false) {
super(_storage);
}
// TODO: Ideally a faster data structure like an index by tableId would be used to decide whether
// the table is onDemand.
@ -37,51 +28,8 @@ export class OnDemandActions {
return tableRef ? Boolean(this._tablesMeta.getValue(tableRef, 'onDemand')) : false;
}
/**
* Convert a UserAction into stored and undo DocActions as well as return values.
*/
public processUserAction(action: UserAction): Promise<ProcessedAction> {
const a = action.map(item => item as any);
switch (a[0]) {
case "ApplyUndoActions": return this._doApplyUndoActions(a[1]);
case "AddRecord": return this._doAddRecord (a[1], a[2], a[3]);
case "BulkAddRecord": return this._doBulkAddRecord (a[1], a[2], a[3]);
case "UpdateRecord": return this._doUpdateRecord (a[1], a[2], a[3]);
case "BulkUpdateRecord": return this._doBulkUpdateRecord(a[1], a[2], a[3]);
case "RemoveRecord": return this._doRemoveRecord (a[1], a[2]);
case "BulkRemoveRecord": return this._doBulkRemoveRecord(a[1], a[2]);
default: throw new Error(`Received unknown action ${action[0]}`);
}
}
/**
* Splits an array of UserActions into two separate arrays of normal and onDemand actions.
*/
public splitByOnDemand(actions: UserAction[]): [UserAction[], UserAction[]] {
const normal: UserAction[] = [];
const onDemand: UserAction[] = [];
actions.forEach(a => {
// Check that the actionType can be applied without the sandbox and also that the action
// is on a data table.
const isOnDemandAction = ACTION_TYPES.has(a[0] as string);
const isDataTableAction = typeof a[1] === 'string' && !a[1].startsWith('_grist_');
if (a[0] === 'ApplyUndoActions') {
// Split actions inside the undo action array.
const [undoNormal, undoOnDemand] = this.splitByOnDemand(a[1] as UserAction[]);
if (undoNormal.length > 0) {
normal.push(['ApplyUndoActions', undoNormal]);
}
if (undoOnDemand.length > 0) {
onDemand.push(['ApplyUndoActions', undoOnDemand]);
}
} else if (isDataTableAction && isOnDemandAction && this.isOnDemand(a[1] as string)) {
// Check whether the tableId belongs to an onDemand table.
onDemand.push(a);
} else {
normal.push(a);
}
});
return [normal, onDemand];
public usesAlternateStorage(tableId: string): boolean {
return this.isOnDemand(tableId);
}
/**
@ -97,112 +45,4 @@ export class OnDemandActions {
}
return desiredIndexes;
}
/**
* Check if an action represents a schema change on an onDemand table.
*/
public isSchemaAction(docAction: DocAction): boolean {
return isSchemaAction(docAction) && this.isOnDemand(docAction[1]);
}
private async _doApplyUndoActions(actions: DocAction[]) {
const undo: DocAction[] = [];
for (const a of actions) {
const converted = await this.processUserAction(a);
undo.concat(converted.undo);
}
return {
stored: actions,
undo,
retValues: null
};
}
private async _doAddRecord(
tableId: string,
rowId: number|null,
colValues: ColValues
): Promise<ProcessedAction> {
if (rowId === null) {
rowId = await this._storage.getNextRowId(tableId);
}
// Set the manualSort to be the same as the rowId. This forces new rows to always be added
// at the end of the table.
colValues.manualSort = rowId;
return {
stored: [['AddRecord', tableId, rowId, colValues]],
undo: [['RemoveRecord', tableId, rowId]],
retValues: rowId
};
}
private async _doBulkAddRecord(
tableId: string,
rowIds: Array<number|null>,
colValues: BulkColValues
): Promise<ProcessedAction> {
// When unset, we will set the rowId values to count up from the greatest
// values already in the table.
if (rowIds[0] === null) {
const nextRowId = await this._storage.getNextRowId(tableId);
for (let i = 0; i < rowIds.length; i++) {
rowIds[i] = nextRowId + i;
}
}
// Set the manualSort values to be the same as the rowIds. This forces new rows to always be
// added at the end of the table.
colValues.manualSort = rowIds;
return {
stored: [['BulkAddRecord', tableId, rowIds as number[], colValues]],
undo: [['BulkRemoveRecord', tableId, rowIds as number[]]],
retValues: rowIds
};
}
private async _doUpdateRecord(
tableId: string,
rowId: number,
colValues: ColValues
): Promise<ProcessedAction> {
const [, , oldRowIds, oldColValues] =
await this._storage.fetchActionData(tableId, [rowId], Object.keys(colValues));
return {
stored: [['UpdateRecord', tableId, rowId, colValues]],
undo: [['BulkUpdateRecord', tableId, oldRowIds, oldColValues]],
retValues: null
};
}
private async _doBulkUpdateRecord(
tableId: string,
rowIds: number[],
colValues: BulkColValues
): Promise<ProcessedAction> {
const [, , oldRowIds, oldColValues] =
await this._storage.fetchActionData(tableId, rowIds, Object.keys(colValues));
return {
stored: [['BulkUpdateRecord', tableId, rowIds, colValues]],
undo: [['BulkUpdateRecord', tableId, oldRowIds, oldColValues]],
retValues: null
};
}
private async _doRemoveRecord(tableId: string, rowId: number): Promise<ProcessedAction> {
const [, , oldRowIds, oldColValues] = await this._storage.fetchActionData(tableId, [rowId]);
return {
stored: [['RemoveRecord', tableId, rowId]],
undo: [['BulkAddRecord', tableId, oldRowIds, oldColValues]],
retValues: null
};
}
private async _doBulkRemoveRecord(tableId: string, rowIds: number[]): Promise<ProcessedAction> {
const [, , oldRowIds, oldColValues] = await this._storage.fetchActionData(tableId, rowIds);
return {
stored: [['BulkRemoveRecord', tableId, rowIds]],
undo: [['BulkAddRecord', tableId, oldRowIds, oldColValues]],
retValues: null
};
}
}

@ -1,6 +1,6 @@
import {ActionSummary, ColumnDelta, createEmptyActionSummary, createEmptyTableDelta} from 'app/common/ActionSummary';
import {CellDelta} from 'app/common/TabularDiff';
import {concatenateSummaries} from 'app/server/lib/ActionSummary';
import {concatenateSummaries} from 'app/common/ActionSummarizer';
import {ISQLiteDB, quoteIdent, ResultRow} from 'app/server/lib/SQLiteDB';
import keyBy = require('lodash/keyBy');
import matches = require('lodash/matches');

@ -6,8 +6,9 @@ import {fromTableDataAction, RowRecord, TableColValues, TableDataAction} from 'a
import {StringUnion} from 'app/common/StringUnion';
import {MetaRowRecord} from 'app/common/TableData';
import {CellDelta} from 'app/common/TabularDiff';
import {WebhookBatchStatus, WebhookStatus, WebhookSummary, WebhookUsage} from 'app/common/Triggers';
import {decodeObject} from 'app/plugin/objtypes';
import {summarizeAction} from 'app/server/lib/ActionSummary';
import {summarizeAction} from 'app/common/ActionSummarizer';
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {makeExceptionalDocSession} from 'app/server/lib/DocSession';
import log from 'app/server/lib/log';
@ -37,44 +38,6 @@ type RecordDeltas = Map<number, RecordDelta>;
// Union discriminated by type
type TriggerAction = WebhookAction | PythonAction;
type WebhookBatchStatus = 'success'|'failure'|'rejected';
type WebhookStatus = 'idle'|'sending'|'retrying'|'postponed'|'error'|'invalid';
export interface WebhookSummary {
id: string;
fields: {
url: string;
unsubscribeKey: string;
eventTypes: string[];
isReadyColumn?: string|null;
tableId: string;
enabled: boolean;
},
usage: WebhookUsage|null,
}
interface WebhookUsage {
// As minimum we need number of waiting events and status (by default pending).
numWaiting: number,
status: WebhookStatus;
updatedTime?: number|null;
lastSuccessTime?: number|null;
lastFailureTime?: number|null;
lastErrorMessage?: string|null;
lastHttpStatus?: number|null;
lastEventBatch?: null | {
size: number;
errorMessage: string|null;
httpStatus: number|null;
status: WebhookBatchStatus;
attempts: number;
},
numSuccess?: {
pastHour: number;
past24Hours: number;
},
}
export interface WebhookAction {
type: "webhook";
id: string;
@ -171,7 +134,7 @@ export class DocTriggers {
// to quit it afterwards and avoid keeping a client open for documents without triggers.
this._getRedisQueuePromise = this._getRedisQueue(createClient(redisUrl));
}
this._stats = new WebhookStatistics(this._docId, () => this._redisClient ?? null);
this._stats = new WebhookStatistics(this._docId, _activeDoc, () => this._redisClient ?? null);
}
public shutdown() {
@ -316,7 +279,9 @@ export class DocTriggers {
isReadyColumn: getColId(t.isReadyColRef) ?? null,
tableId: getTableId(t.tableRef) ?? null,
// For future use - for now every webhook is enabled.
enabled: true,
enabled: t.enabled,
name: t.label,
memo: t.memo,
},
// Create some statics and status info.
usage: await this._stats.getUsage(act.id, this._webHookEventQueue),
@ -346,6 +311,10 @@ export class DocTriggers {
public webhookDeleted(id: string) {
// We can't do much about that as the loop might be in progress and it is not safe to modify the queue.
// But we can clear the webHook cache, so that the next time we check the webhook url it will be gone.
this.clearWebhookCache(id);
}
public clearWebhookCache(id: string) {
this._webhookCache.delete(id);
}
@ -527,7 +496,9 @@ export class DocTriggers {
tableDelta: TableDelta,
): boolean {
let readyBefore: boolean;
if (!trigger.isReadyColRef) {
if (!trigger.enabled) {
return false;
} else if (!trigger.isReadyColRef) {
// User hasn't configured a column, so all records are considered ready immediately
readyBefore = recordDelta.existedBefore;
} else {
@ -821,6 +792,7 @@ class PersistedStore<Keys> {
constructor(
docId: string,
private _activeDoc: ActiveDoc,
private _redisClientDep: () => RedisClient | null
) {
this._redisKey = `webhooks:${docId}:statistics`;
@ -833,6 +805,10 @@ class PersistedStore<Keys> {
}
}
protected async markChange() {
await this._activeDoc.sendWebhookNotification();
}
protected async set(id: string, keyValues: [Keys, string][]) {
if (this._redisClient) {
const multi = this._redisClient.multi();
@ -939,6 +915,7 @@ class WebhookStatistics extends PersistedStore<StatsKey> {
stats.push(['errorMessage', '']);
}
await this.set(id, stats);
await this.markChange();
}
public async logInvalid(id: string, errorMessage: string) {
@ -946,6 +923,7 @@ class WebhookStatistics extends PersistedStore<StatsKey> {
await this.set(id, [
['errorMessage', errorMessage]
]);
await this.markChange();
}
/**
@ -995,6 +973,7 @@ class WebhookStatistics extends PersistedStore<StatsKey> {
batchSummary.push([`lastHttpStatus`, (stats.httpStatus || '').toString()]);
}
await this.set(id, batchStats.concat(batchSummary));
await this.markChange();
}
}

@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',37,'','');
INSERT INTO _grist_DocInfo VALUES(1,'','','',38,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY,
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT '');
INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'','');
CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT '');
@ -43,7 +43,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',37,'','');
INSERT INTO _grist_DocInfo VALUES(1,'','','',38,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
@ -75,7 +75,7 @@ INSERT INTO _grist_Views_section_field VALUES(6,2,6,4,0,'',0,0,'',NULL);
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT '');
INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'','');
CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT '');

@ -1189,7 +1189,6 @@ def migration35(tdset):
return tdset.apply_doc_actions(doc_actions)
@migration(schema_version=36)
def migration36(tdset):
"""
@ -1197,10 +1196,22 @@ def migration36(tdset):
"""
return tdset.apply_doc_actions([add_column('_grist_Tables_column', 'description', 'Text')])
@migration(schema_version=37)
def migration37(tdset):
"""
Add fileExt column to _grist_Attachments.
"""
return tdset.apply_doc_actions([add_column('_grist_Attachments', 'fileExt', 'Text')])
@migration(schema_version=38)
def migration38(tdset):
doc_actions = [add_column('_grist_Triggers', 'memo', 'Text'),
add_column('_grist_Triggers', 'label', 'Text'),
add_column('_grist_Triggers', 'enabled', 'Bool')]
triggers = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Triggers']))
doc_actions.append(actions.BulkUpdateRecord(
'_grist_Triggers',
[t.id for t in triggers],
{'enabled': [True for t in triggers]}
))
return tdset.apply_doc_actions(doc_actions)

@ -15,7 +15,7 @@ import six
import actions
SCHEMA_VERSION = 37
SCHEMA_VERSION = 38
def make_column(col_id, col_type, formula='', isFormula=False):
return {
@ -254,6 +254,9 @@ def schema_create_actions():
make_column("eventTypes", "ChoiceList"),
make_column("isReadyColRef", "Ref:_grist_Tables_column"),
make_column("actions", "Text"), # JSON
make_column("label", "Text"),
make_column("memo", "Text"),
make_column("enabled", "Bool"),
]),
# All of the ACL rules.

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -0,0 +1,275 @@
import { DocCreationInfo } from 'app/common/DocListAPI';
import { DocAPI } from 'app/common/UserAPI';
import { assert, driver, Key } from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import { setupTestSuite } from 'test/nbrowser/testUtils';
import { EnvironmentSnapshot } from 'test/server/testUtils';
import { server } from 'test/nbrowser/testUtils';
//import { Deps as TriggersDeps } from 'app/server/lib/Triggers';
describe('WebhookPage', function () {
this.timeout(30000);
const cleanup = setupTestSuite();
let session: gu.Session;
let oldEnv: EnvironmentSnapshot;
let docApi: DocAPI;
let doc: DocCreationInfo;
let host: string;
before(async function () {
oldEnv = new EnvironmentSnapshot();
host = new URL(server.getHost()).host;
process.env.ALLOWED_WEBHOOK_DOMAINS = host;
await server.restart();
session = await gu.session().teamSite.login();
const api = session.createHomeApi();
doc = await session.tempDoc(cleanup, 'Hello.grist');
docApi = api.getDocAPI(doc.id);
await api.applyUserActions(doc.id, [
['AddTable', 'Table2', [{id: 'A'}, {id: 'B'}, {id: 'C'}, {id: 'D'}, {id: 'E'}]],
]);
await api.applyUserActions(doc.id, [
['AddTable', 'Table3', [{id: 'A'}, {id: 'B'}, {id: 'C'}, {id: 'D'}, {id: 'E'}]],
]);
await api.updateDocPermissions(doc.id, {
users: {
// for convenience, we'll be sending payloads to the document itself.
'anon@getgrist.com': 'editors',
// check another owner's perspective.
[gu.session().user('user2').email]: 'owners',
}
});
});
after(async function () {
oldEnv.restore();
});
it('starts with an empty card', async function () {
await openWebhookPage();
assert.equal(await gu.getCardListCount(), 1); // includes empty card
assert.sameDeepMembers(await gu.getCardFieldLabels(), [
'Name',
'Memo',
'Event Types',
'URL',
'Table',
'Ready Column',
'Webhook Id',
'Enabled',
'Status',
]);
});
it('can create a persistent webhook', async function () {
// Set up a webhook for Table1, and send it to Table2 (for ease of testing).
await openWebhookPage();
await setField(1, 'Event Types', 'add\nupdate\n');
await setField(1, 'URL', `http://${host}/api/docs/${doc.id}/tables/Table2/records?flat=1`);
await setField(1, 'Table', 'Table1');
// Once event types, URL, and table are set, the webhook is created.
// Up until that point, nothing we've entered is actually persisted,
// there is no back end for it.
await gu.waitToPass(async () => {
assert.include(await getField(1, 'Webhook Id'), '-');
});
const id = await getField(1, 'Webhook Id');
// Reload and make sure the webhook id is still there.
await driver.navigate().refresh();
await waitForWebhookPage();
await gu.waitToPass(async () => {
assert.equal(await getField(1, 'Webhook Id'), id);
});
// Now other fields like name and memo are persisted.
await setField(1, 'Name', 'Test Webhook');
await setField(1, 'Memo', 'Test Memo');
await gu.waitForServer();
await driver.navigate().refresh();
await waitForWebhookPage();
await gu.waitToPass(async () => {
assert.equal(await getField(1, 'Name'), 'Test Webhook');
assert.equal(await getField(1, 'Memo'), 'Test Memo');
});
// Make sure the webhook is actually working.
await docApi.addRows('Table1', {A: ['zig'], B: ['zag']});
// Make sure the data gets delivered, and that the webhook status is updated.
await gu.waitToPass(async () => {
assert.lengthOf((await docApi.getRows('Table2')).A, 1);
assert.equal((await docApi.getRows('Table2')).A[0], 'zig');
assert.match(await getField(1, 'Status'), /status...success/);
});
// Remove the webhook and make sure it is no longer listed.
assert.equal(await gu.getCardListCount(), 2);
await gu.getDetailCell({col: 'Name', rowNum: 1}).click();
await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));
await gu.confirm(true, true);
await gu.waitForServer();
assert.equal(await gu.getCardListCount(), 1);
await driver.navigate().refresh();
await waitForWebhookPage();
assert.equal(await gu.getCardListCount(), 1);
await docApi.removeRows('Table2', [1]);
assert.lengthOf((await docApi.getRows('Table2')).A, 0);
});
it('can create two webhooks', async function () {
await openWebhookPage();
await setField(1, 'Event Types', 'add\nupdate\n');
await setField(1, 'URL', `http://${host}/api/docs/${doc.id}/tables/Table2/records?flat=1`);
await setField(1, 'Table', 'Table1');
await gu.waitForServer();
await setField(2, 'Event Types', 'add\n');
await setField(2, 'URL', `http://${host}/api/docs/${doc.id}/tables/Table3/records?flat=1`);
await setField(2, 'Table', 'Table1');
await gu.waitForServer();
await docApi.addRows('Table1', {A: ['zig2'], B: ['zag2']});
await gu.waitToPass(async () => {
assert.lengthOf((await docApi.getRows('Table2')).A, 1);
assert.lengthOf((await docApi.getRows('Table3')).A, 1);
assert.match(await getField(1, 'Status'), /status...success/);
assert.match(await getField(2, 'Status'), /status...success/);
});
await docApi.updateRows('Table1', {id: [1], A: ['zig3'], B: ['zag3']});
await gu.waitToPass(async () => {
assert.lengthOf((await docApi.getRows('Table2')).A, 2);
assert.lengthOf((await docApi.getRows('Table3')).A, 1);
assert.match(await getField(1, 'Status'), /status...success/);
});
await driver.sleep(100);
// confirm that nothing shows up to Table3.
assert.lengthOf((await docApi.getRows('Table3')).A, 1);
// Break everything down.
await gu.getDetailCell({col: 'Name', rowNum: 1}).click();
await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));
await gu.confirm(true, true);
await gu.waitForServer();
await gu.getDetailCell({col: 'Memo', rowNum: 1}).click();
await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));
await gu.waitForServer();
assert.equal(await gu.getCardListCount(), 1);
await driver.navigate().refresh();
await waitForWebhookPage();
assert.equal(await gu.getCardListCount(), 1);
await docApi.removeRows('Table2', [1, 2]);
await docApi.removeRows('Table3', [1]);
assert.lengthOf((await docApi.getRows('Table2')).A, 0);
assert.lengthOf((await docApi.getRows('Table3')).A, 0);
});
it('can create and repair a dud webhook', async function () {
await openWebhookPage();
await setField(1, 'Event Types', 'add\nupdate\n');
await setField(1, 'URL', `http://${host}/notathing`);
await setField(1, 'Table', 'Table1');
await gu.waitForServer();
await docApi.addRows('Table1', {A: ['dud1']});
await gu.waitToPass(async () => {
assert.match(await getField(1, 'Status'), /status...failure/);
assert.match(await getField(1, 'Status'), /numWaiting..1/);
});
await setField(1, 'URL', `http://${host}/api/docs/${doc.id}/tables/Table2/records?flat=1`);
await driver.findContent('button', /Clear Queue/).click();
await gu.waitForServer();
await gu.waitToPass(async () => {
assert.match(await getField(1, 'Status'), /numWaiting..0/);
});
assert.lengthOf((await docApi.getRows('Table2')).A, 0);
await docApi.addRows('Table1', {A: ['dud2']});
await gu.waitToPass(async () => {
assert.lengthOf((await docApi.getRows('Table2')).A, 1);
assert.match(await getField(1, 'Status'), /status...success/);
});
// Break everything down.
await gu.getDetailCell({col: 'Name', rowNum: 1}).click();
await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));
await gu.confirm(true, true);
await gu.waitForServer();
await docApi.removeRows('Table2', [1]);
assert.lengthOf((await docApi.getRows('Table2')).A, 0);
});
it('can keep multiple sessions in sync', async function () {
await openWebhookPage();
// Open another tab.
await driver.executeScript("return window.open('about:blank', '_blank')");
const [ownerTab, owner2Tab] = await driver.getAllWindowHandles();
await driver.switchTo().window(owner2Tab);
const otherSession = await gu.session().teamSite.user('user2').login();
await otherSession.loadDoc(`/doc/${doc.id}`);
await openWebhookPage();
await setField(1, 'Event Types', 'add\nupdate\n');
await setField(1, 'URL', `http://${host}/multiple`);
await setField(1, 'Table', 'Table1');
await gu.waitForServer();
await driver.switchTo().window(ownerTab);
await gu.waitToPass(async () => {
assert.match(await getField(1, 'URL'), /multiple/);
});
assert.equal(await gu.getCardListCount(), 2);
await setField(1, 'Memo', 'multiple memo');
await driver.switchTo().window(owner2Tab);
await gu.waitToPass(async () => {
assert.match(await getField(1, 'Memo'), /multiple memo/);
});
// Basic undo support.
await driver.switchTo().window(ownerTab);
await gu.undo();
await gu.waitToPass(async () => {
assert.equal(await getField(1, 'Memo'), '');
});
await driver.switchTo().window(owner2Tab);
await gu.waitToPass(async () => {
assert.equal(await getField(1, 'Memo'), '');
});
// Basic redo support.
await driver.switchTo().window(ownerTab);
await gu.redo();
await gu.waitToPass(async () => {
assert.match(await getField(1, 'Memo'), /multiple memo/);
});
await driver.switchTo().window(owner2Tab);
await gu.waitToPass(async () => {
assert.match(await getField(1, 'Memo'), /multiple memo/);
});
await gu.getDetailCell({col: 'Name', rowNum: 1}).click();
await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));
await gu.confirm(true, true);
await driver.switchTo().window(ownerTab);
await gu.waitToPass(async () => {
assert.equal(await gu.getCardListCount(), 1);
});
await driver.switchTo().window(owner2Tab);
await driver.close();
await driver.switchTo().window(ownerTab);
});
});
async function setField(rowNum: number, col: string, text: string) {
await gu.getDetailCell({col, rowNum}).click();
await gu.enterCell(text);
}
async function getField(rowNum: number, col: string) {
const cell = await gu.getDetailCell({col, rowNum});
return cell.getText();
}
async function openWebhookPage() {
await gu.openDocumentSettings();
const button = await driver.findContentWait('a', /Manage Webhooks/, 3000);
await gu.scrollIntoView(button).click();
await waitForWebhookPage();
}
async function waitForWebhookPage() {
await driver.findContentWait('button', /Clear Queue/, 3000);
// No section, so no easy utility for setting focus. Click on a random cell.
await gu.getDetailCell({col: 'Webhook Id', rowNum: 1}).click();
}

@ -5,6 +5,7 @@ import { setupTestSuite } from 'test/nbrowser/testUtils';
describe("saveViewSection", function() {
this.timeout(20000);
setupTestSuite();
gu.bigScreen();
const cleanup = setupTestSuite();

@ -2,6 +2,7 @@
import {ActionSummary} from 'app/common/ActionSummary';
import {BulkColValues, UserAction} from 'app/common/DocActions';
import {arrayRepeat} from 'app/common/gutil';
import {WebhookSummary} from 'app/common/Triggers';
import {DocAPI, DocState, UserAPIImpl} from 'app/common/UserAPI';
import {testDailyApiLimitFeatures} from 'app/gen-server/entity/Product';
import {AddOrUpdateRecord, Record as ApiRecord} from 'app/plugin/DocApiTypes';
@ -15,7 +16,6 @@ import {
} from 'app/server/lib/DocApi';
import log from 'app/server/lib/log';
import {delayAbort} from 'app/server/lib/serverUtils';
import {WebhookSummary} from 'app/server/lib/Triggers';
import axios, {AxiosRequestConfig, AxiosResponse} from 'axios';
import {delay} from 'bluebird';
import * as bodyParser from 'body-parser';
@ -1421,26 +1421,28 @@ function testDocApi() {
it("should validate request schema", async function () {
const url = `${serverUrl}/api/docs/${docIds.TestDoc}/tables/Foo/records`;
const test = async (payload: any, error: { error: string, details: string }) => {
const test = async (payload: any, error: { error: string, details: {userError: string} }) => {
const resp = await axios.put(url, payload, chimpy);
checkError(400, error, resp);
};
await test({}, {error: 'Invalid payload', details: 'Error: body.records is missing'});
await test({records: 1}, {error: 'Invalid payload', details: 'Error: body.records is not an array'});
await test({}, {error: 'Invalid payload', details: {userError: 'Error: body.records is missing'}});
await test({records: 1}, {
error: 'Invalid payload',
details: {userError: 'Error: body.records is not an array'}});
await test({records: [{fields: {}}]},
{
error: 'Invalid payload',
details: 'Error: ' +
details: {userError: 'Error: ' +
'body.records[0] is not a AddOrUpdateRecord; ' +
'body.records[0].require is missing',
});
}});
await test({records: [{require: {id: "1"}}]},
{
error: 'Invalid payload',
details: 'Error: ' +
details: {userError: 'Error: ' +
'body.records[0] is not a AddOrUpdateRecord; ' +
'body.records[0].require.id is not a number',
});
}});
});
});
@ -1462,23 +1464,23 @@ function testDocApi() {
it("validates request schema", async function () {
const url = `${serverUrl}/api/docs/${docIds.TestDoc}/tables/Foo/records`;
const test = async (payload: any, error: { error: string, details: string }) => {
const test = async(payload: any, error: {error: string, details: {userError: string}}) => {
const resp = await axios.post(url, payload, chimpy);
checkError(400, error, resp);
};
await test({}, {error: 'Invalid payload', details: 'Error: body.records is missing'});
await test({records: 1}, {error: 'Invalid payload', details: 'Error: body.records is not an array'});
await test({}, {error: 'Invalid payload', details: {userError: 'Error: body.records is missing'}});
await test({records: 1}, {
error: 'Invalid payload',
details: {userError: 'Error: body.records is not an array'}});
// All column types are allowed, except Arrays (or objects) without correct code.
const testField = async (A: any) => {
await test({records: [{id: 1, fields: {A}}]}, {
error: 'Invalid payload', details:
'Error: body.records[0] is not a NewRecord; ' +
'body.records[0].fields.A is not a CellValue; ' +
'body.records[0].fields.A is none of number, ' +
'string, boolean, null, 1 more; body.records[0].' +
'fields.A[0] is not a GristObjCode; body.records[0]' +
'.fields.A[0] is not a valid enum value'
});
await test({records: [{ id: 1, fields: { A } }]}, {error: 'Invalid payload', details: {userError:
'Error: body.records[0] is not a NewRecord; '+
'body.records[0].fields.A is not a CellValue; '+
'body.records[0].fields.A is none of number, '+
'string, boolean, null, 1 more; body.records[0].'+
'fields.A[0] is not a GristObjCode; body.records[0]'+
'.fields.A[0] is not a valid enum value'}});
};
// test no code at all
await testField([]);
@ -1627,34 +1629,29 @@ function testDocApi() {
it("validates request schema", async function () {
const url = `${serverUrl}/api/docs/${docIds.TestDoc}/tables/Foo/records`;
async function failsWithError(payload: any, error: { error: string, details?: string }) {
async function failsWithError(payload: any, error: { error: string, details?: {userError: string} }){
const resp = await axios.patch(url, payload, chimpy);
checkError(400, error, resp);
}
await failsWithError({}, {error: 'Invalid payload', details: 'Error: body.records is missing'});
await failsWithError({}, {error: 'Invalid payload', details: {userError: 'Error: body.records is missing'}});
await failsWithError({records: 1}, {error: 'Invalid payload', details: 'Error: body.records is not an array'});
await failsWithError({records: 1}, {
error: 'Invalid payload',
details: {userError: 'Error: body.records is not an array'}});
await failsWithError({records: []}, {
error: 'Invalid payload', details:
'Error: body.records[0] is not a Record; body.records[0] is not an object'
});
await failsWithError({records: []}, {error: 'Invalid payload', details: {userError:
'Error: body.records[0] is not a Record; body.records[0] is not an object'}});
await failsWithError({records: [{}]}, {
error: 'Invalid payload', details:
'Error: body.records[0] is not a Record\n ' +
'body.records[0].id is missing\n ' +
'body.records[0].fields is missing'
});
await failsWithError({records: [{}]}, {error: 'Invalid payload', details: {userError:
'Error: body.records[0] is not a Record\n '+
'body.records[0].id is missing\n '+
'body.records[0].fields is missing'}});
await failsWithError({records: [{id: "1"}]}, {
error: 'Invalid payload', details:
'Error: body.records[0] is not a Record\n' +
' body.records[0].id is not a number\n' +
' body.records[0].fields is missing'
});
await failsWithError({records: [{id: "1"}]}, {error: 'Invalid payload', details: {userError:
'Error: body.records[0] is not a Record\n' +
' body.records[0].id is not a number\n' +
' body.records[0].fields is missing'}});
await failsWithError(
{records: [{id: 1, fields: {A: 1}}, {id: 2, fields: {B: 3}}]},
@ -1662,15 +1659,13 @@ function testDocApi() {
// Test invalid object codes
const fieldIsNotValid = async (A: any) => {
await failsWithError({records: [{id: 1, fields: {A}}]}, {
error: 'Invalid payload', details:
'Error: body.records[0] is not a Record; ' +
'body.records[0].fields.A is not a CellValue; ' +
'body.records[0].fields.A is none of number, ' +
'string, boolean, null, 1 more; body.records[0].' +
'fields.A[0] is not a GristObjCode; body.records[0]' +
'.fields.A[0] is not a valid enum value'
});
await failsWithError({records: [{ id: 1, fields: { A } }]}, {error: 'Invalid payload', details: {userError:
'Error: body.records[0] is not a Record; '+
'body.records[0].fields.A is not a CellValue; '+
'body.records[0].fields.A is none of number, '+
'string, boolean, null, 1 more; body.records[0].'+
'fields.A[0] is not a GristObjCode; body.records[0]'+
'.fields.A[0] is not a valid enum value'}});
};
await fieldIsNotValid([]);
await fieldIsNotValid(['ZZ']);
@ -2785,7 +2780,7 @@ function testDocApi() {
);
assert.equal(resp.status, status);
for (const error of errors) {
assert.match(resp.data.details || resp.data.error, error);
assert.match(resp.data.details?.userError || resp.data.error, error);
}
}
@ -3133,8 +3128,10 @@ function testDocApi() {
async function subscribe(endpoint: string, docId: string, options?: {
tableId?: string,
isReadyColumn?: string | null,
eventTypes?: string[]
isReadyColumn?: string|null,
eventTypes?: string[],
name?: string,
memo?: string,
}) {
// Subscribe helper that returns a method to unsubscribe.
const {data, status} = await axios.post(
@ -3142,7 +3139,8 @@ function testDocApi() {
{
eventTypes: options?.eventTypes ?? ['add', 'update'],
url: `${serving.url}/${endpoint}`,
isReadyColumn: options?.isReadyColumn === undefined ? 'B' : options?.isReadyColumn
isReadyColumn: options?.isReadyColumn === undefined ? 'B' : options?.isReadyColumn,
...pick(options, 'name', 'memo'),
}, chimpy
);
assert.equal(status, 200);
@ -3640,8 +3638,10 @@ function testDocApi() {
eventTypes: ['add', 'update'],
enabled: true,
isReadyColumn: 'B',
tableId: 'Table1'
}, usage: {
tableId: 'Table1',
name: '',
memo: '',
}, usage : {
status: 'idle',
numWaiting: 0,
lastEventBatch: null
@ -3655,8 +3655,10 @@ function testDocApi() {
eventTypes: ['add', 'update'],
enabled: true,
isReadyColumn: 'B',
tableId: 'Table1'
}, usage: {
tableId: 'Table1',
name: '',
memo: '',
}, usage : {
status: 'idle',
numWaiting: 0,
lastEventBatch: null
@ -3966,6 +3968,8 @@ function testDocApi() {
tableId: 'Table1',
eventTypes: ['add'],
isReadyColumn: 'B',
name: 'My Webhook',
memo: 'Sync store',
};
// subscribe
@ -3978,6 +3982,8 @@ function testDocApi() {
isReadyColumn: 'B',
tableId: 'Table1',
enabled: true,
name: 'My Webhook',
memo: 'Sync store',
};
let stats = await readStats(docId);
@ -4005,7 +4011,7 @@ function testDocApi() {
}
} else {
if (error instanceof RegExp) {
assert.match(resp.data.details || resp.data.error, error);
assert.match(resp.data.details?.userError || resp.data.error, error);
} else {
assert.deepEqual(resp.data, {error});
}

@ -57,7 +57,7 @@ TODO: this hardcoded port numbers might cause conflicts in parallel tests execut
const webhooksTestPort = 34365;
const webhooksTestProxyPort = 22335;
describe('Webhooks proxy configuration', function () {
describe('Webhooks-Proxy', function () {
// A testDir of the form grist_test_{USER}_{SERVER_NAME}
// - its a directory that will be base for all test related files and activities
const username = process.env.USER || "nobody";

Loading…
Cancel
Save