mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user