(core) deleting queue from single webhook

Summary: Using standard tost notification, message about webhook queue being overflown was added. message is permanent as long as queue is full. Message contains linkt to the webhook setings

Test Plan: two nbrowser test was added - one to check if message is show when queue is full, and second to check if message is dismiss when queue was cleaned.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D3929
This commit is contained in:
Jakub Serafin 2023-07-18 09:24:10 +02:00
parent 450472f74c
commit d894b60fd4
14 changed files with 475 additions and 151 deletions

View File

@ -18,7 +18,6 @@ import {EditorMonitor} from "app/client/components/EditorMonitor";
import * as GridView from 'app/client/components/GridView'; import * as GridView from 'app/client/components/GridView';
import {Importer} from 'app/client/components/Importer'; import {Importer} from 'app/client/components/Importer';
import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage'; import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage';
import {DocSettingsPage} from 'app/client/ui/DocumentSettings';
import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack'; import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack';
import {ViewLayout} from 'app/client/components/ViewLayout'; import {ViewLayout} from 'app/client/components/ViewLayout';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
@ -43,6 +42,7 @@ import {App} from 'app/client/ui/App';
import {DocHistory} from 'app/client/ui/DocHistory'; import {DocHistory} from 'app/client/ui/DocHistory';
import {startDocTour} from "app/client/ui/DocTour"; import {startDocTour} from "app/client/ui/DocTour";
import {DocTutorial} from 'app/client/ui/DocTutorial'; import {DocTutorial} from 'app/client/ui/DocTutorial';
import {DocSettingsPage} from 'app/client/ui/DocumentSettings';
import {isTourActive} from "app/client/ui/OnBoardingPopups"; import {isTourActive} from "app/client/ui/OnBoardingPopups";
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkFromId, selectBy} from 'app/client/ui/selectBy'; import {linkFromId, selectBy} from 'app/client/ui/selectBy';
@ -54,8 +54,8 @@ import {isNarrowScreen, mediaSmall, mediaXSmall, testId, theme} from 'app/client
import {IconName} from 'app/client/ui2018/IconList'; import {IconName} from 'app/client/ui2018/IconList';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {invokePrompt} from 'app/client/ui2018/modals'; import {invokePrompt} from 'app/client/ui2018/modals';
import {FieldEditor} from "app/client/widgets/FieldEditor";
import {DiscussionPanel} from 'app/client/widgets/DiscussionEditor'; import {DiscussionPanel} from 'app/client/widgets/DiscussionEditor';
import {FieldEditor} from "app/client/widgets/FieldEditor";
import {MinimalActionGroup} from 'app/common/ActionGroup'; import {MinimalActionGroup} from 'app/common/ActionGroup';
import {ClientQuery} from "app/common/ActiveDocAPI"; import {ClientQuery} from "app/common/ActiveDocAPI";
import {CommDocChatter, CommDocUsage, CommDocUserAction} from 'app/common/CommTypes'; import {CommDocChatter, CommDocUsage, CommDocUserAction} from 'app/common/CommTypes';
@ -120,8 +120,9 @@ const RightPanelTool = StringUnion("none", "docHistory", "validations", "discuss
export interface IExtraTool { export interface IExtraTool {
icon: IconName; icon: IconName;
label: DomContents; label: DomContents;
content: TabContent[]|IDomComponent; content: TabContent[] | IDomComponent;
} }
interface RawSectionOptions { interface RawSectionOptions {
viewSection: ViewSectionRec; viewSection: ViewSectionRec;
hash: HashLink; hash: HashLink;
@ -137,10 +138,10 @@ export class GristDoc extends DisposableWithEvents {
public docInfo: DocInfoRec; public docInfo: DocInfoRec;
public docPluginManager: DocPluginManager; public docPluginManager: DocPluginManager;
public querySetManager: QuerySetManager; public querySetManager: QuerySetManager;
public rightPanelTool: Observable<IExtraTool|null>; public rightPanelTool: Observable<IExtraTool | null>;
public isReadonly = this.docPageModel.isReadonly; public isReadonly = this.docPageModel.isReadonly;
public isReadonlyKo = toKo(ko, this.isReadonly); public isReadonlyKo = toKo(ko, this.isReadonly);
public comparison: DocStateComparison|null; public comparison: DocStateComparison | null;
// component for keeping track of latest cursor position // component for keeping track of latest cursor position
public cursorMonitor: CursorMonitor; public cursorMonitor: CursorMonitor;
// component for keeping track of a cell that is being edited // component for keeping track of a cell that is being edited
@ -174,11 +175,11 @@ export class GristDoc extends DisposableWithEvents {
// section (or raw data section) that is not part of this view. Maximized section is a section // section (or raw data section) that is not part of this view. Maximized section is a section
// in the view, so there is no need to render it twice, layout just hides all other sections to make // in the view, so there is no need to render it twice, layout just hides all other sections to make
// the space. // the space.
public maximizedSectionId: Observable<number|null> = Observable.create(this, null); public maximizedSectionId: Observable<number | null> = Observable.create(this, null);
// This is id of the section that is currently shown in the popup. Probably this is an external // This is id of the section that is currently shown in the popup. Probably this is an external
// section, like raw data view, or a section from another view.. // section, like raw data view, or a section from another view..
public externalSectionId: Computed<number|null>; public externalSectionId: Computed<number | null>;
public viewLayout: ViewLayout|null = null; public viewLayout: ViewLayout | null = null;
// Holder for the popped up formula editor. // Holder for the popped up formula editor.
public readonly formulaPopup = Holder.create(this); public readonly formulaPopup = Holder.create(this);
@ -191,15 +192,15 @@ export class GristDoc extends DisposableWithEvents {
private _actionLog: ActionLog; private _actionLog: ActionLog;
private _undoStack: UndoStack; private _undoStack: UndoStack;
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null; private _lastOwnActionGroup: ActionGroupWithCursorPos | null = null;
private _rightPanelTabs = new Map<string, TabContent[]>(); private _rightPanelTabs = new Map<string, TabContent[]>();
private _docHistory: DocHistory; private _docHistory: DocHistory;
private _discussionPanel: DiscussionPanel; private _discussionPanel: DiscussionPanel;
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard); private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour'); private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours'); private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours');
private _rawSectionOptions: Observable<RawSectionOptions|null> = Observable.create(this, null); private _rawSectionOptions: Observable<RawSectionOptions | null> = Observable.create(this, null);
private _activeContent: Computed<IDocPage|RawSectionOptions>; private _activeContent: Computed<IDocPage | RawSectionOptions>;
private _docTutorialHolder = Holder.create<DocTutorial>(this); private _docTutorialHolder = Holder.create<DocTutorial>(this);
private _isRickRowing: Observable<boolean> = Observable.create(this, false); private _isRickRowing: Observable<boolean> = Observable.create(this, false);
private _showBackgroundVideoPlayer: Observable<boolean> = Observable.create(this, false); private _showBackgroundVideoPlayer: Observable<boolean> = Observable.create(this, false);
@ -285,9 +286,13 @@ export class GristDoc extends DisposableWithEvents {
} }
})); }));
// Subscribe to URL state, and navigate to anchor or open a popup if necessary. // Subscribe to URL state, and navigate to anchor or open a popup if necessary.
this.autoDispose(subscribe(urlState().state, async (use, state) => { this.autoDispose(subscribe(urlState().state, async (use, state) => {
if (!state.hash) { return; } if (!state.hash) {
return;
}
try { try {
if (state.hash.popup) { if (state.hash.popup) {
@ -321,7 +326,9 @@ export class GristDoc extends DisposableWithEvents {
this._waitForView() this._waitForView()
.then(() => { .then(() => {
const cursor = document.querySelector('.selected_cursor.active_cursor'); const cursor = document.querySelector('.selected_cursor.active_cursor');
if (!cursor) { return; } if (!cursor) {
return;
}
this.behavioralPromptsManager.showTip(cursor, 'rickRow', { this.behavioralPromptsManager.showTip(cursor, 'rickRow', {
forceShow: true, forceShow: true,
@ -427,8 +434,8 @@ export class GristDoc extends DisposableWithEvents {
// Set the available import sources in the DocPageModel. // Set the available import sources in the DocPageModel.
this.docPageModel.importSources = importMenuItems; this.docPageModel.importSources = importMenuItems;
this._actionLog = this.autoDispose(ActionLog.create({ gristDoc: this })); this._actionLog = this.autoDispose(ActionLog.create({gristDoc: this}));
this._undoStack = this.autoDispose(UndoStack.create(openDocResponse.log, { gristDoc: this })); this._undoStack = this.autoDispose(UndoStack.create(openDocResponse.log, {gristDoc: this}));
this._docHistory = DocHistory.create(this, this.docPageModel, this._actionLog); this._docHistory = DocHistory.create(this, this.docPageModel, this._actionLog);
this._discussionPanel = DiscussionPanel.create(this, this); this._discussionPanel = DiscussionPanel.create(this, this);
@ -439,9 +446,15 @@ export class GristDoc extends DisposableWithEvents {
/* Command binding */ /* Command binding */
this.autoDispose(commands.createGroup({ this.autoDispose(commands.createGroup({
undo() { this._undoStack.sendUndoAction().catch(reportError); }, undo() {
redo() { this._undoStack.sendRedoAction().catch(reportError); }, this._undoStack.sendUndoAction().catch(reportError);
reloadPlugins() { void this.docComm.reloadPlugins().then(() => G.window.location.reload(false)); }, },
redo() {
this._undoStack.sendRedoAction().catch(reportError);
},
reloadPlugins() {
void this.docComm.reloadPlugins().then(() => G.window.location.reload(false));
},
// Command to be manually triggered on cell selection. Moves the cursor to the selected cell. // Command to be manually triggered on cell selection. Moves the cursor to the selected cell.
// This is overridden by the formula editor to insert "$col" variables when clicking cells. // This is overridden by the formula editor to insert "$col" variables when clicking cells.
@ -454,6 +467,8 @@ export class GristDoc extends DisposableWithEvents {
this.listenTo(app.comm, 'docChatter', this.onDocChatter); this.listenTo(app.comm, 'docChatter', this.onDocChatter);
this._handleTriggerQueueOverflowMessage();
this.autoDispose(DocConfigTab.create({gristDoc: this})); this.autoDispose(DocConfigTab.create({gristDoc: this}));
this.rightPanelTool = Computed.create(this, (use) => this._getToolContent(use(this._rightPanelTool))); this.rightPanelTool = Computed.create(this, (use) => this._getToolContent(use(this._rightPanelTool)));
@ -478,7 +493,9 @@ export class GristDoc extends DisposableWithEvents {
// switch for a moment to knockout to fix this. // switch for a moment to knockout to fix this.
const viewInstance = fromKo(this.autoDispose(ko.pureComputed(() => { const viewInstance = fromKo(this.autoDispose(ko.pureComputed(() => {
const viewId = toKo(ko, this.activeViewId)(); const viewId = toKo(ko, this.activeViewId)();
if (!isViewDocPage(viewId)) { return null; } if (!isViewDocPage(viewId)) {
return null;
}
const section = this.viewModel.activeSection(); const section = this.viewModel.activeSection();
if (section?.isDisposed()) { return null; } if (section?.isDisposed()) { return null; }
const view = section.viewInstance(); const view = section.viewInstance();
@ -490,7 +507,9 @@ export class GristDoc extends DisposableWithEvents {
if (view) { if (view) {
await view.getLoadingDonePromise(); await view.getLoadingDonePromise();
} }
if (view?.isDisposed()) { return; } if (view?.isDisposed()) {
return;
}
// finally set the current view as fully loaded // finally set the current view as fully loaded
this.currentView.set(view); this.currentView.set(view);
})); }));
@ -499,12 +518,18 @@ export class GristDoc extends DisposableWithEvents {
this.cursorPosition = Computed.create<ViewCursorPos | undefined>(this, use => { this.cursorPosition = Computed.create<ViewCursorPos | undefined>(this, use => {
// get the BaseView // get the BaseView
const view = use(this.currentView); const view = use(this.currentView);
if (!view) { return undefined; } if (!view) {
return undefined;
}
const viewId = use(this.activeViewId); const viewId = use(this.activeViewId);
if (!isViewDocPage(viewId)) { return undefined; } if (!isViewDocPage(viewId)) {
return undefined;
}
// read latest position // read latest position
const currentPosition = use(view.cursor.currentPosition); const currentPosition = use(view.cursor.currentPosition);
if (currentPosition) { return { ...currentPosition, viewId }; } if (currentPosition) {
return {...currentPosition, viewId};
}
return undefined; return undefined;
}); });
@ -520,7 +545,9 @@ export class GristDoc extends DisposableWithEvents {
// When active section is changed to a chart or custom widget, change the tab in the creator // When active section is changed to a chart or custom widget, change the tab in the creator
// panel to the table. // panel to the table.
this.autoDispose(this.viewModel.activeSection.subscribe((section) => { this.autoDispose(this.viewModel.activeSection.subscribe((section) => {
if (section.isDisposed() || section._isDeleted.peek()) { return; } if (section.isDisposed() || section._isDeleted.peek()) {
return;
}
if (['chart', 'custom'].includes(section.parentKey.peek())) { if (['chart', 'custom'].includes(section.parentKey.peek())) {
commands.allCommands.viewTabFocus.run(); commands.allCommands.viewTabFocus.run();
} }
@ -609,12 +636,12 @@ export class GristDoc extends DisposableWithEvents {
* fields { sectionId, rowId, fieldIndex }. Fields may be missing if no section is active. * fields { sectionId, rowId, fieldIndex }. Fields may be missing if no section is active.
*/ */
public getCursorPos(): CursorPos { public getCursorPos(): CursorPos {
const pos = { sectionId: this.viewModel.activeSectionId() }; const pos = {sectionId: this.viewModel.activeSectionId()};
const viewInstance = this.viewModel.activeSection.peek().viewInstance.peek(); const viewInstance = this.viewModel.activeSection.peek().viewInstance.peek();
return Object.assign(pos, viewInstance ? viewInstance.cursor.getCursorPos() : {}); return Object.assign(pos, viewInstance ? viewInstance.cursor.getCursorPos() : {});
} }
public async onSetCursorPos(rowModel: BaseRowModel|undefined, fieldModel?: ViewFieldRec) { public async onSetCursorPos(rowModel: BaseRowModel | undefined, fieldModel?: ViewFieldRec) {
return this.setCursorPos({ return this.setCursorPos({
rowIndex: rowModel?._index() || 0, rowIndex: rowModel?._index() || 0,
fieldIndex: fieldModel?._index() || 0, fieldIndex: fieldModel?._index() || 0,
@ -669,7 +696,7 @@ export class GristDoc extends DisposableWithEvents {
} }
try { try {
await this.setCursorPos(cursorPos); await this.setCursorPos(cursorPos);
} catch(e) { } catch (e) {
reportError(e); reportError(e);
} }
} }
@ -732,7 +759,9 @@ export class GristDoc extends DisposableWithEvents {
* observables. * observables.
*/ */
public onDocUsageMessage(message: CommDocUsage) { public onDocUsageMessage(message: CommDocUsage) {
if (!this.docComm.isActionFromThisDoc(message)) { return; } if (!this.docComm.isActionFromThisDoc(message)) {
return;
}
bundleChanges(() => { bundleChanges(() => {
this.docPageModel.updateCurrentDocUsage(message.data.docUsage); this.docPageModel.updateCurrentDocUsage(message.data.docUsage);
@ -741,8 +770,15 @@ export class GristDoc extends DisposableWithEvents {
} }
public onDocChatter(message: CommDocChatter) { public onDocChatter(message: CommDocChatter) {
if (!this.docComm.isActionFromThisDoc(message)) { return; } if (!this.docComm.isActionFromThisDoc(message) ||
if (message.data.webhooks) { !message.data.webhooks) {
return;
}
if (message.data.webhooks.type == 'webhookOverflowError') {
this.trigger('webhookOverflowError',
t('New changes are temporarily suspended. Webhooks queue overflowed.' +
' Please check webhooks settings, remove invalid webhooks, and clean the queue.'),);
} else {
this.trigger('webhooks', message.data.webhooks); this.trigger('webhooks', message.data.webhooks);
} }
} }
@ -755,7 +791,9 @@ export class GristDoc extends DisposableWithEvents {
// in effect. // in effect.
public getTableModelMaybeWithDiff(tableId: string): DataTableModel { public getTableModelMaybeWithDiff(tableId: string): DataTableModel {
const tableModel = this.getTableModel(tableId); const tableModel = this.getTableModel(tableId);
if (!this.comparison?.details) { return tableModel; } if (!this.comparison?.details) {
return tableModel;
}
// TODO: cache wrapped models and share between views. // TODO: cache wrapped models and share between views.
return new DataTableModelWithDiff(tableModel, this.comparison.details); return new DataTableModelWithDiff(tableModel, this.comparison.details);
} }
@ -765,7 +803,9 @@ export class GristDoc extends DisposableWithEvents {
*/ */
public async addEmptyTable(): Promise<void> { public async addEmptyTable(): Promise<void> {
const name = await this._promptForName(); const name = await this._promptForName();
if (name === undefined) { return; } if (name === undefined) {
return;
}
const tableInfo = await this.docData.sendAction(['AddEmptyTable', name || null]); const tableInfo = await this.docData.sendAction(['AddEmptyTable', name || null]);
await this.openDocPage(this.docModel.tables.getRowModel(tableInfo.id).primaryViewId()); await this.openDocPage(this.docModel.tables.getRowModel(tableInfo.id).primaryViewId());
} }
@ -776,7 +816,7 @@ export class GristDoc extends DisposableWithEvents {
public async addWidgetToPage(val: IPageWidget) { public async addWidgetToPage(val: IPageWidget) {
const docData = this.docModel.docData; const docData = this.docModel.docData;
const viewName = this.viewModel.name.peek(); const viewName = this.viewModel.name.peek();
let tableId: string|null|undefined; let tableId: string | null | undefined;
if (val.table === 'New Table') { if (val.table === 'New Table') {
tableId = await this._promptForName(); tableId = await this._promptForName();
if (tableId === undefined) { if (tableId === undefined) {
@ -797,7 +837,7 @@ export class GristDoc extends DisposableWithEvents {
/** /**
* The actual implementation of addWidgetToPage * The actual implementation of addWidgetToPage
*/ */
public async addWidgetToPageImpl(val: IPageWidget, tableId: string|null = null) { public async addWidgetToPageImpl(val: IPageWidget, tableId: string | null = null) {
const viewRef = this.activeViewId.get(); const viewRef = this.activeViewId.get();
const tableRef = val.table === 'New Table' ? 0 : val.table; const tableRef = val.table === 'New Table' ? 0 : val.table;
const result = await this.docData.sendAction( const result = await this.docData.sendAction(
@ -816,7 +856,9 @@ export class GristDoc extends DisposableWithEvents {
public async addNewPage(val: IPageWidget) { public async addNewPage(val: IPageWidget) {
if (val.table === 'New Table') { if (val.table === 'New Table') {
const name = await this._promptForName(); const name = await this._promptForName();
if (name === undefined) { return; } if (name === undefined) {
return;
}
const result = await this.docData.sendAction(['AddEmptyTable', name]); const result = await this.docData.sendAction(['AddEmptyTable', name]);
await this.openDocPage(result.views[0].id); await this.openDocPage(result.views[0].id);
} else { } else {
@ -842,8 +884,10 @@ export class GristDoc extends DisposableWithEvents {
* primary view. * primary view.
*/ */
public async uploadNewTable(): Promise<void> { public async uploadNewTable(): Promise<void> {
const uploadResult = await selectFiles({docWorkerUrl: this.docComm.docWorkerUrl, const uploadResult = await selectFiles({
multiple: true}); docWorkerUrl: this.docComm.docWorkerUrl,
multiple: true
});
if (uploadResult) { if (uploadResult) {
const dataSource = {uploadId: uploadResult.uploadId, transforms: []}; const dataSource = {uploadId: uploadResult.uploadId, transforms: []};
const importResult = await this.docComm.finishImportFiles(dataSource, [], {}); const importResult = await this.docComm.finishImportFiles(dataSource, [], {});
@ -865,7 +909,7 @@ export class GristDoc extends DisposableWithEvents {
} }
return await this.viewLayout!.freezeUntil(docData.bundleActions( return await this.viewLayout!.freezeUntil(docData.bundleActions(
t("Saved linked section {{title}} in view {{name}}", {title:section.title(), name: viewModel.name()}), t("Saved linked section {{title}} in view {{name}}", {title: section.title(), name: viewModel.name()}),
async () => { async () => {
// if table changes or a table is made a summary table, let's replace the view section by a // if table changes or a table is made a summary table, let's replace the view section by a
@ -932,7 +976,9 @@ export class GristDoc extends DisposableWithEvents {
// adds new view fields; ignore colIds that do not exist in new table. // adds new view fields; ignore colIds that do not exist in new table.
await Promise.all(colIds.map((colId, i) => { await Promise.all(colIds.map((colId, i) => {
if (!mapColIdToColumn.has(colId)) { return; } if (!mapColIdToColumn.has(colId)) {
return;
}
const colInfo = { const colInfo = {
parentId: section.id(), parentId: section.id(),
colRef: mapColIdToColumn.get(colId).id(), colRef: mapColIdToColumn.get(colId).id(),
@ -983,7 +1029,7 @@ export class GristDoc extends DisposableWithEvents {
} }
// Turn the given columns into empty columns, losing any data stored in them. // Turn the given columns into empty columns, losing any data stored in them.
public async clearColumns(colRefs: number[], {keepType}: {keepType?: boolean} = {}): Promise<void> { public async clearColumns(colRefs: number[], {keepType}: { keepType?: boolean } = {}): Promise<void> {
await this.docModel.columns.sendTableAction( await this.docModel.columns.sendTableAction(
['BulkUpdateRecord', colRefs, { ['BulkUpdateRecord', colRefs, {
isFormula: colRefs.map(f => true), isFormula: colRefs.map(f => true),
@ -1003,7 +1049,7 @@ export class GristDoc extends DisposableWithEvents {
} }
// Convert the given columns to data, saving the calculated values and unsetting the formulas. // Convert the given columns to data, saving the calculated values and unsetting the formulas.
public async convertIsFormula(colRefs: number[], opts: {toFormula: boolean, noRecalc?: boolean}): Promise<void> { public async convertIsFormula(colRefs: number[], opts: { toFormula: boolean, noRecalc?: boolean }): Promise<void> {
return this.docModel.columns.sendTableAction( return this.docModel.columns.sendTableAction(
['BulkUpdateRecord', colRefs, { ['BulkUpdateRecord', colRefs, {
isFormula: colRefs.map(f => opts.toFormula), isFormula: colRefs.map(f => opts.toFormula),
@ -1076,8 +1122,12 @@ export class GristDoc extends DisposableWithEvents {
setAsActiveSection: boolean, setAsActiveSection: boolean,
silent: boolean = false): Promise<boolean> { silent: boolean = false): Promise<boolean> {
try { try {
if (!cursorPos.sectionId) { throw new Error('sectionId required'); } if (!cursorPos.sectionId) {
if (!cursorPos.rowId) { throw new Error('rowId required'); } throw new Error('sectionId required');
}
if (!cursorPos.rowId) {
throw new Error('rowId required');
}
const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId); const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
if (!section.id.peek()) { if (!section.id.peek()) {
throw new Error(`Section ${cursorPos.sectionId} does not exist`); throw new Error(`Section ${cursorPos.sectionId} does not exist`);
@ -1125,7 +1175,9 @@ export class GristDoc extends DisposableWithEvents {
} }
srcRowId = srcTable.getRowIds().find(getFilterFunc(this.docData, query)); srcRowId = srcTable.getRowIds().find(getFilterFunc(this.docData, query));
} }
if (!srcRowId || typeof srcRowId !== 'number') { throw new Error('cannot trace rowId'); } if (!srcRowId || typeof srcRowId !== 'number') {
throw new Error('cannot trace rowId');
}
await this.recursiveMoveToCursorPos({ await this.recursiveMoveToCursorPos({
rowId: srcRowId, rowId: srcRowId,
sectionId: srcSection.id.peek(), sectionId: srcSection.id.peek(),
@ -1133,14 +1185,20 @@ export class GristDoc extends DisposableWithEvents {
} }
const view: ViewRec = section.view.peek(); const view: ViewRec = section.view.peek();
const docPage: ViewDocPage = section.isRaw.peek() ? "data" : view.getRowId(); const docPage: ViewDocPage = section.isRaw.peek() ? "data" : view.getRowId();
if (docPage != this.activeViewId.get()) { await this.openDocPage(docPage); } if (docPage != this.activeViewId.get()) {
if (setAsActiveSection) { view.activeSectionId(cursorPos.sectionId); } await this.openDocPage(docPage);
}
if (setAsActiveSection) {
view.activeSectionId(cursorPos.sectionId);
}
const fieldIndex = cursorPos.fieldIndex; const fieldIndex = cursorPos.fieldIndex;
const viewInstance = await waitObs(section.viewInstance); const viewInstance = await waitObs(section.viewInstance);
if (!viewInstance) { throw new Error('view not found'); } if (!viewInstance) {
throw new Error('view not found');
}
// Give any synchronous initial cursor setting a chance to happen. // Give any synchronous initial cursor setting a chance to happen.
await delay(0); await delay(0);
viewInstance.setCursorPos({ ...cursorPos, fieldIndex }); viewInstance.setCursorPos({...cursorPos, fieldIndex});
// TODO: column selection not working on card/detail view, or getting overridden - // TODO: column selection not working on card/detail view, or getting overridden -
// look into it (not a high priority for now since feature not easily discoverable // look into it (not a high priority for now since feature not easily discoverable
// in this view). // in this view).
@ -1162,7 +1220,7 @@ export class GristDoc extends DisposableWithEvents {
* Opens up an editor at cursor position * Opens up an editor at cursor position
* @param input Optional. Cell's initial value * @param input Optional. Cell's initial value
*/ */
public async activateEditorAtCursor(options?: { init?: string, state?: any}) { public async activateEditorAtCursor(options?: { init?: string, state?: any }) {
const view = await this._waitForView(); const view = await this._waitForView();
view?.activateEditorAtCursor(options); view?.activateEditorAtCursor(options);
} }
@ -1183,7 +1241,9 @@ export class GristDoc extends DisposableWithEvents {
*/ */
public async openPopup(hash: HashLink) { public async openPopup(hash: HashLink) {
// We can only open a popup for a section. // We can only open a popup for a section.
if (!hash.sectionId) { return; } if (!hash.sectionId) {
return;
}
// We might open popup either for a section in this view or some other section (like Raw Data Page). // We might open popup either for a section in this view or some other section (like Raw Data Page).
if (this.viewModel.viewSections.peek().peek().some(s => s.id.peek() === hash.sectionId)) { if (this.viewModel.viewSections.peek().peek().some(s => s.id.peek() === hash.sectionId)) {
if (this.viewLayout) { if (this.viewLayout) {
@ -1196,7 +1256,7 @@ export class GristDoc extends DisposableWithEvents {
const fieldIndex = activeSection.viewFields.peek().all().findIndex(f => f.colRef.peek() === hash.colRef); const fieldIndex = activeSection.viewFields.peek().all().findIndex(f => f.colRef.peek() === hash.colRef);
if (fieldIndex >= 0) { if (fieldIndex >= 0) {
const view = await this._waitForView(activeSection); const view = await this._waitForView(activeSection);
view?.setCursorPos({ sectionId: hash.sectionId, rowId: hash.rowId, fieldIndex }); view?.setCursorPos({sectionId: hash.sectionId, rowId: hash.rowId, fieldIndex});
} }
} }
this.viewLayout?.maximized.set(hash.sectionId); this.viewLayout?.maximized.set(hash.sectionId);
@ -1219,17 +1279,23 @@ export class GristDoc extends DisposableWithEvents {
viewSection: popupSection, viewSection: popupSection,
close: () => { close: () => {
// In case we are already close, do nothing. // In case we are already close, do nothing.
if (!this._rawSectionOptions.get()) { return; } if (!this._rawSectionOptions.get()) {
return;
}
if (popupSection !== prevSection) { if (popupSection !== prevSection) {
// We need to blur raw view section. Otherwise it will automatically be opened // We need to blur raw view section. Otherwise it will automatically be opened
// on raw data view. Note: raw data section doesn't have its own view, it uses // on raw data view. Note: raw data section doesn't have its own view, it uses
// empty row model as a parent (which feels like a hack). // empty row model as a parent (which feels like a hack).
if (!popupSection.isDisposed()) { popupSection.hasFocus(false); } if (!popupSection.isDisposed()) {
popupSection.hasFocus(false);
}
// We need to restore active viewSection for a view that we borrowed. // We need to restore active viewSection for a view that we borrowed.
// When this popup was opened we tricked active view by setting its activeViewSection // When this popup was opened we tricked active view by setting its activeViewSection
// to our viewSection (which might be a completely diffrent section or a raw data section) not // to our viewSection (which might be a completely diffrent section or a raw data section) not
// connected to this view. // connected to this view.
if (!prevSection.isDisposed()) { prevSection.hasFocus(true); } if (!prevSection.isDisposed()) {
prevSection.hasFocus(true);
}
} }
// Clearing popup data will close this popup. // Clearing popup data will close this popup.
this._rawSectionOptions.set(null); this._rawSectionOptions.set(null);
@ -1240,7 +1306,7 @@ export class GristDoc extends DisposableWithEvents {
const fieldIndex = popupSection.viewFields.peek().all().findIndex(f => f.colRef.peek() === hash.colRef); const fieldIndex = popupSection.viewFields.peek().all().findIndex(f => f.colRef.peek() === hash.colRef);
if (fieldIndex >= 0) { if (fieldIndex >= 0) {
const view = await this._waitForView(popupSection); const view = await this._waitForView(popupSection);
view?.setCursorPos({ sectionId: hash.sectionId, rowId: hash.rowId, fieldIndex }); view?.setCursorPos({sectionId: hash.sectionId, rowId: hash.rowId, fieldIndex});
} }
} }
} }
@ -1250,7 +1316,9 @@ export class GristDoc extends DisposableWithEvents {
*/ */
public async playRickRollVideo() { public async playRickRollVideo() {
const backgroundVideoPlayer = this._backgroundVideoPlayerHolder.get(); const backgroundVideoPlayer = this._backgroundVideoPlayerHolder.get();
if (!backgroundVideoPlayer) { return; } if (!backgroundVideoPlayer) {
return;
}
await backgroundVideoPlayer.isLoaded(); await backgroundVideoPlayer.isLoaded();
backgroundVideoPlayer.play(); backgroundVideoPlayer.play();
@ -1272,7 +1340,9 @@ export class GristDoc extends DisposableWithEvents {
await setVolume(0, 100, 5); await setVolume(0, 100, 5);
await delay(190 * 1000); await delay(190 * 1000);
if (!this._isRickRowing.get()) { return; } if (!this._isRickRowing.get()) {
return;
}
await setVolume(100, 0, 5); await setVolume(100, 0, 5);
@ -1289,6 +1359,7 @@ export class GristDoc extends DisposableWithEvents {
if (!sectionToCheck.getRowId()) { if (!sectionToCheck.getRowId()) {
return null; return null;
} }
async function singleWait(s: ViewSectionRec): Promise<BaseView> { async function singleWait(s: ViewSectionRec): Promise<BaseView> {
const view = await waitObs( const view = await waitObs(
sectionToCheck.viewInstance, sectionToCheck.viewInstance,
@ -1296,6 +1367,7 @@ export class GristDoc extends DisposableWithEvents {
); );
return view!; return view!;
} }
let view = await singleWait(sectionToCheck); let view = await singleWait(sectionToCheck);
if (view.isDisposed()) { if (view.isDisposed()) {
// If the view is disposed (it can happen, as wait is not reliable enough, because it uses // If the view is disposed (it can happen, as wait is not reliable enough, because it uses
@ -1350,7 +1422,9 @@ export class GristDoc extends DisposableWithEvents {
await commands.allCommands.rightPanelOpen.run(); await commands.allCommands.rightPanelOpen.run();
const editLayoutButton = document.querySelector('.behavioral-prompt-edit-card-layout'); const editLayoutButton = document.querySelector('.behavioral-prompt-edit-card-layout');
if (!editLayoutButton) { throw new Error('GristDoc failed to find edit card layout button'); } if (!editLayoutButton) {
throw new Error('GristDoc failed to find edit card layout button');
}
this.behavioralPromptsManager.showTip(editLayoutButton, 'editCardLayout', { this.behavioralPromptsManager.showTip(editLayoutButton, 'editCardLayout', {
popupOptions: { popupOptions: {
@ -1424,7 +1498,7 @@ export class GristDoc extends DisposableWithEvents {
* Helper called before an action is sent to the server. It saves cursor position to come back to * Helper called before an action is sent to the server. It saves cursor position to come back to
* in case of Undo. * in case of Undo.
*/ */
private _onSendActionsStart(ev: {cursorPos: CursorPos}) { private _onSendActionsStart(ev: { cursorPos: CursorPos }) {
this._lastOwnActionGroup = null; this._lastOwnActionGroup = null;
ev.cursorPos = this.getCursorPos(); ev.cursorPos = this.getCursorPos();
} }
@ -1433,7 +1507,7 @@ export class GristDoc extends DisposableWithEvents {
* Helper called when server responds to an action. It attaches the saved cursor position to the * Helper called when server responds to an action. It attaches the saved cursor position to the
* received action (if any), and stores also the resulting position. * received action (if any), and stores also the resulting position.
*/ */
private _onSendActionsEnd(ev: {cursorPos: CursorPos}) { private _onSendActionsEnd(ev: { cursorPos: CursorPos }) {
const a = this._lastOwnActionGroup; const a = this._lastOwnActionGroup;
if (a) { if (a) {
a.cursorPos = ev.cursorPos; a.cursorPos = ev.cursorPos;
@ -1445,15 +1519,15 @@ export class GristDoc extends DisposableWithEvents {
private _getDocApiDownloadParams() { private _getDocApiDownloadParams() {
const filters = this.viewModel.activeSection.peek().activeFilters.get().map(filterInfo => ({ const filters = this.viewModel.activeSection.peek().activeFilters.get().map(filterInfo => ({
colRef : filterInfo.fieldOrColumn.origCol().origColRef(), colRef: filterInfo.fieldOrColumn.origCol().origColRef(),
filter : filterInfo.filter() filter: filterInfo.filter()
})); }));
const params = { const params = {
viewSection: this.viewModel.activeSectionId(), viewSection: this.viewModel.activeSectionId(),
tableId: this.viewModel.activeSection().table().tableId(), tableId: this.viewModel.activeSection().table().tableId(),
activeSortSpec: JSON.stringify(this.viewModel.activeSection().activeSortSpec()), activeSortSpec: JSON.stringify(this.viewModel.activeSection().activeSortSpec()),
filters : JSON.stringify(filters), filters: JSON.stringify(filters),
}; };
return params; return params;
} }
@ -1485,10 +1559,14 @@ export class GristDoc extends DisposableWithEvents {
private async _getTableData(section: ViewSectionRec): Promise<TableData> { private async _getTableData(section: ViewSectionRec): Promise<TableData> {
const viewInstance = await waitObs(section.viewInstance); const viewInstance = await waitObs(section.viewInstance);
if (!viewInstance) { throw new Error('view not found'); } if (!viewInstance) {
throw new Error('view not found');
}
await viewInstance.getLoadingDonePromise(); await viewInstance.getLoadingDonePromise();
const table = this.docData.getTable(section.table.peek().tableId.peek()); const table = this.docData.getTable(section.table.peek().tableId.peek());
if (!table) { throw new Error('no section table'); } if (!table) {
throw new Error('no section table');
}
return table; return table;
} }
@ -1496,13 +1574,15 @@ export class GristDoc extends DisposableWithEvents {
* Convert a url hash to a cursor position. * Convert a url hash to a cursor position.
*/ */
private _getCursorPosFromHash(hash: HashLink): CursorPos { private _getCursorPosFromHash(hash: HashLink): CursorPos {
const cursorPos: CursorPos = { rowId: hash.rowId, sectionId: hash.sectionId }; const cursorPos: CursorPos = {rowId: hash.rowId, sectionId: hash.sectionId};
if (cursorPos.sectionId != undefined && hash.colRef !== undefined){ if (cursorPos.sectionId != undefined && hash.colRef !== undefined) {
// translate colRef to a fieldIndex // translate colRef to a fieldIndex
const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId); const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
const fieldIndex = section.viewFields.peek().all() const fieldIndex = section.viewFields.peek().all()
.findIndex(x=> x.colRef.peek() == hash.colRef); .findIndex(x => x.colRef.peek() == hash.colRef);
if (fieldIndex >= 0) { cursorPos.fieldIndex = fieldIndex; } if (fieldIndex >= 0) {
cursorPos.fieldIndex = fieldIndex;
}
} }
return cursorPos; return cursorPos;
} }
@ -1556,7 +1636,9 @@ export class GristDoc extends DisposableWithEvents {
const viewFields = viewSection.viewFields.peek().peek(); const viewFields = viewSection.viewFields.peek().peek();
// If no y-series, then simply return. // If no y-series, then simply return.
if (viewFields.length === 1) { return; } if (viewFields.length === 1) {
return;
}
const field = viewSection.viewFields.peek().peek()[1]; const field = viewSection.viewFields.peek().peek()[1];
if (isNumericOnly(viewSection.chartTypeDef.peek()) && if (isNumericOnly(viewSection.chartTypeDef.peek()) &&
@ -1580,10 +1662,28 @@ export class GristDoc extends DisposableWithEvents {
await this.docModel.viewFields.sendTableActions(actions); await this.docModel.viewFields.sendTableActions(actions);
} }
} }
private _handleTriggerQueueOverflowMessage() {
this.listenTo(this, 'webhookOverflowError', (err: any) => {
this.app.topAppModel.notifier.createNotification({
message: err.toString(),
canUserClose: false,
level: "error",
badgeCounter: true,
expireSec: 5,
key: 'webhookOverflowError',
actions: [{
label: t('go to webhook settings'), action: async () => {
await urlState().pushUrl({docPage: 'webhook'});
}
}]
});
});
}
} }
async function finalizeAnchor() { async function finalizeAnchor() {
await urlState().pushUrl({ hash: {} }, { replace: true }); await urlState().pushUrl({hash: {}}, {replace: true});
setTestState({anchorApplied: true}); setTestState({anchorApplied: true});
} }

View File

@ -266,7 +266,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
explanation: ( explanation: (
isDocOwner isDocOwner
? t("You can try reloading the document, or using recovery mode. \ ? t("You can try reloading the document, or using recovery mode. \
Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. \ Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. \
It also disables formulas. [{{error}}]", {error: err.message}) It also disables formulas. [{{error}}]", {error: err.message})
: isDenied : isDenied
? t('Sorry, access to this document has been denied. [{{error}}]', {error: err.message}) ? t('Sorry, access to this document has been denied. [{{error}}]', {error: err.message})

View File

@ -1,14 +1,25 @@
import * as log from 'app/client/lib/log'; import * as log from 'app/client/lib/log';
import {ConnectState, ConnectStateManager} from 'app/client/models/ConnectState'; import {ConnectState, ConnectStateManager} from 'app/client/models/ConnectState';
import {isNarrowScreenObs, testId} from 'app/client/ui2018/cssVars';
import {delay} from 'app/common/delay'; import {delay} from 'app/common/delay';
import {isLongerThan} from 'app/common/gutil'; import {isLongerThan} from 'app/common/gutil';
import {InactivityTimer} from 'app/common/InactivityTimer'; import {InactivityTimer} from 'app/common/InactivityTimer';
import {timeFormat} from 'app/common/timeFormat'; import {timeFormat} from 'app/common/timeFormat';
import {bundleChanges, Disposable, Holder, IDisposable, IDisposableOwner } from 'grainjs'; import {
import {Computed, dom, DomElementArg, MutableObsArray, obsArray, Observable} from 'grainjs'; bundleChanges,
Computed,
Disposable,
dom,
DomElementArg,
Holder,
IDisposable,
IDisposableOwner,
MutableObsArray,
obsArray,
Observable
} from 'grainjs';
import clamp = require('lodash/clamp'); import clamp = require('lodash/clamp');
import defaults = require('lodash/defaults'); import defaults = require('lodash/defaults');
import {isNarrowScreenObs, testId} from 'app/client/ui2018/cssVars';
// When rendering app errors, we'll only show the last few. // When rendering app errors, we'll only show the last few.
const maxAppErrors = 5; const maxAppErrors = 5;
@ -79,9 +90,11 @@ export class Expirable extends Disposable {
/** /**
* Sets status to 'expiring', then calls dispose after a short delay. * Sets status to 'expiring', then calls dispose after a short delay.
*/ */
public async expire(): Promise<void> { public async expire(withoutDelay: boolean = false): Promise<void> {
this.status.set('expiring'); this.status.set('expiring');
if(!withoutDelay) {
await delay(Expirable.fadeDelay); await delay(Expirable.fadeDelay);
}
if (!this.isDisposed()) { if (!this.isDisposed()) {
this.dispose(); this.dispose();
} }
@ -339,7 +352,7 @@ export class Notifier extends Disposable implements INotifier {
if (key) { if (key) {
const prev = this._keyedItems.get(key); const prev = this._keyedItems.get(key);
if (prev) { if (prev) {
await prev.expire(); await prev.expire(true);
} }
this._keyedItems.set(key, n); this._keyedItems.set(key, n);
n.onDispose(() => this.isDisposed() || this._keyedItems.delete(key)); n.onDispose(() => this.isDisposed() || this._keyedItems.delete(key));

View File

@ -1,19 +1,26 @@
import { GristDoc } from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import { ViewSectionHelper } from 'app/client/components/ViewLayout'; import {ViewSectionHelper} from 'app/client/components/ViewLayout';
import { makeT } from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import { reportMessage, reportSuccess } from 'app/client/models/errors'; import {reportMessage, reportSuccess} from 'app/client/models/errors';
import { IEdit, IExternalTable, VirtualTable } from 'app/client/models/VirtualTable'; import {IEdit, IExternalTable, VirtualTable} from 'app/client/models/VirtualTable';
import { docListHeader } from 'app/client/ui/DocMenuCss'; import {docListHeader} from 'app/client/ui/DocMenuCss';
import { bigPrimaryButton } from 'app/client/ui2018/buttons'; import {bigPrimaryButton} from 'app/client/ui2018/buttons';
import { mediaSmall, testId } from 'app/client/ui2018/cssVars'; import {mediaSmall, testId} from 'app/client/ui2018/cssVars';
import { ApiError } from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import { DisposableWithEvents } from 'app/common/DisposableWithEvents'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import { DocAction, getColIdsFromDocAction, getColValues, import {
isDataAction, TableDataAction, UserAction } from 'app/common/DocActions'; DocAction,
import { WebhookSummary } from 'app/common/Triggers'; getColIdsFromDocAction,
import { DocAPI } from 'app/common/UserAPI'; getColValues,
import { GristObjCode, RowRecord } from 'app/plugin/GristData'; isDataAction,
import { dom, styled } from 'grainjs'; 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 {observableArray, ObservableArray} from "knockout";
import omit = require('lodash/omit'); import omit = require('lodash/omit');
import pick = require('lodash/pick'); import pick = require('lodash/pick');
import range = require('lodash/range'); import range = require('lodash/range');
@ -122,11 +129,14 @@ class WebhookExternalTable implements IExternalTable {
public saveableFields = [ public saveableFields = [
'tableId', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn', 'tableId', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn',
]; ];
public webhooks: ObservableArray<WebhookSummary> = observableArray<WebhookSummary>([]);
public constructor(private _docApi: DocAPI) {} public constructor(private _docApi: DocAPI) {
}
public async fetchAll(): Promise<TableDataAction> { public async fetchAll(): Promise<TableDataAction> {
const webhooks = await this._docApi.getWebhooks(); const webhooks = await this._docApi.getWebhooks();
this._initalizeWebhookList(webhooks);
const indices = range(webhooks.length); const indices = range(webhooks.length);
return ['TableData', this.name, indices.map(i => i + 1), return ['TableData', this.name, indices.map(i => i + 1),
getColValues(indices.map(rowId => _mapWebhookValues(webhooks[rowId])))]; getColValues(indices.map(rowId => _mapWebhookValues(webhooks[rowId])))];
@ -136,7 +146,9 @@ class WebhookExternalTable implements IExternalTable {
const results = editor.actions; const results = editor.actions;
for (const r of results) { for (const r of results) {
for (const d of r.stored) { for (const d of r.stored) {
if (!isDataAction(d)) { continue; } if (!isDataAction(d)) {
continue;
}
const colIds = new Set(getColIdsFromDocAction(d) || []); const colIds = new Set(getColIdsFromDocAction(d) || []);
if (colIds.has('webhookId') || colIds.has('status')) { if (colIds.has('webhookId') || colIds.has('status')) {
throw new Error(`Sorry, not all fields can be edited.`); throw new Error(`Sorry, not all fields can be edited.`);
@ -146,7 +158,9 @@ class WebhookExternalTable implements IExternalTable {
const delta = editor.delta; const delta = editor.delta;
for (const recId of delta.removeRows) { for (const recId of delta.removeRows) {
const rec = editor.getRecord(recId); const rec = editor.getRecord(recId);
if (!rec) { continue; } if (!rec) {
continue;
}
await this._removeWebhook(rec); await this._removeWebhook(rec);
reportMessage(`Removed webhook.`); reportMessage(`Removed webhook.`);
} }
@ -159,13 +173,16 @@ class WebhookExternalTable implements IExternalTable {
} }
} }
} }
public async afterEdit(editor: IEdit) { public async afterEdit(editor: IEdit) {
const {delta} = editor; const {delta} = editor;
const updates = new Set(delta.updateRows); const updates = new Set(delta.updateRows);
const addsAndUpdates = new Set([...delta.addRows, ...delta.updateRows]); const addsAndUpdates = new Set([...delta.addRows, ...delta.updateRows]);
for (const recId of addsAndUpdates) { for (const recId of addsAndUpdates) {
const rec = editor.getRecord(recId); const rec = editor.getRecord(recId);
if (!rec) { continue; } if (!rec) {
continue;
}
const notes: string[] = []; const notes: string[] = [];
const values: Record<string, any> = {}; const values: Record<string, any> = {};
if (!rec.webhookId) { if (!rec.webhookId) {
@ -192,6 +209,12 @@ class WebhookExternalTable implements IExternalTable {
} }
} }
private _initalizeWebhookList(webhooks: WebhookSummary[]){
this.webhooks.removeAll();
this.webhooks.push(...webhooks);
}
public async sync(editor: IEdit): Promise<void> { public async sync(editor: IEdit): Promise<void> {
// Map from external webhookId to local arbitrary rowId. // Map from external webhookId to local arbitrary rowId.
const rowMap = new Map(editor.getRowIds().map(rowId => [editor.getRecord(rowId)!.webhookId, rowId])); const rowMap = new Map(editor.getRowIds().map(rowId => [editor.getRecord(rowId)!.webhookId, rowId]));
@ -206,6 +229,7 @@ class WebhookExternalTable implements IExternalTable {
// webhooks, or that "updating" something that hasn't actually // webhooks, or that "updating" something that hasn't actually
// changed is not disruptive. // changed is not disruptive.
const webhooks = await this._docApi.getWebhooks(); const webhooks = await this._docApi.getWebhooks();
this._initalizeWebhookList(webhooks);
for (const webhook of webhooks) { for (const webhook of webhooks) {
const values = _mapWebhookValues(webhook); const values = _mapWebhookValues(webhook);
const rowId = rowMap.get(webhook.id); const rowId = rowMap.get(webhook.id);
@ -296,16 +320,22 @@ export class WebhookPage extends DisposableWithEvents {
public docApi = this.gristDoc.docPageModel.appModel.api.getDocAPI(this.gristDoc.docId()); public docApi = this.gristDoc.docPageModel.appModel.api.getDocAPI(this.gristDoc.docId());
public sharedTable: VirtualTable; public sharedTable: VirtualTable;
private _webhookExternalTable: WebhookExternalTable;
constructor(public gristDoc: GristDoc) { constructor(public gristDoc: GristDoc) {
super(); super();
//this._webhooks = observableArray<WebhookSummary>();
const table = new VirtualTable(this, gristDoc, new WebhookExternalTable(this.docApi)); this._webhookExternalTable = new WebhookExternalTable(this.docApi);
const table = new VirtualTable(this, gristDoc, this._webhookExternalTable);
this.listenTo(gristDoc, 'webhooks', async () => { this.listenTo(gristDoc, 'webhooks', async () => {
await table.lazySync(); await table.lazySync();
}); });
} }
public buildDom() { public buildDom() {
const viewSectionModel = this.gristDoc.docModel.viewSections.getRowModel('vt_webhook_fs1' as any); const viewSectionModel = this.gristDoc.docModel.viewSections.getRowModel('vt_webhook_fs1' as any);
ViewSectionHelper.create(this, this.gristDoc, viewSectionModel); ViewSectionHelper.create(this, this.gristDoc, viewSectionModel);
@ -315,7 +345,7 @@ export class WebhookPage extends DisposableWithEvents {
bigPrimaryButton(t("Clear Queue"), bigPrimaryButton(t("Clear Queue"),
dom.on('click', () => this.reset()), dom.on('click', () => this.reset()),
testId('webhook-reset'), testId('webhook-reset'),
), )
), ),
// active_section here is a bit of a hack, to allow tests to run // active_section here is a bit of a hack, to allow tests to run
// more easily. // more easily.
@ -327,6 +357,11 @@ export class WebhookPage extends DisposableWithEvents {
await this.docApi.flushWebhooks(); await this.docApi.flushWebhooks();
reportSuccess('Cleared webhook queue.'); reportSuccess('Cleared webhook queue.');
} }
public async resetSelected(id: string) {
await this.docApi.flushWebhook(id);
reportSuccess(`Cleared webhook ${id} queue.`);
}
} }
const cssHeader = styled(docListHeader, ` const cssHeader = styled(docListHeader, `
@ -377,7 +412,7 @@ function _prepareWebhookInitialActions(tableId: string): DocAction[] {
})) }))
], [ ], [
// Add an entry for the virtual table. // Add an entry for the virtual table.
'AddRecord', '_grist_Tables', 'vt_webhook_ft1' as any, { tableId, primaryViewId: 0 }, 'AddRecord', '_grist_Tables', 'vt_webhook_ft1' as any, {tableId, primaryViewId: 0},
], [ ], [
// Add entries for the columns of the virtual table. // Add entries for the columns of the virtual table.
'BulkAddRecord', '_grist_Tables_column', 'BulkAddRecord', '_grist_Tables_column',
@ -391,10 +426,10 @@ function _prepareWebhookInitialActions(tableId: string): DocAction[] {
], [ ], [
// Add a view section. // Add a view section.
'AddRecord', '_grist_Views_section', 'vt_webhook_fs1' as any, 'AddRecord', '_grist_Views_section', 'vt_webhook_fs1' as any,
{ tableRef: 'vt_webhook_ft1', parentKey: 'detail', title: '', borderWidth: 1, defaultWidth: 100, theme: 'blocks' } {tableRef: 'vt_webhook_ft1', parentKey: 'detail', title: '', borderWidth: 1, defaultWidth: 100, theme: 'blocks'}
], [ ], [
// List the fields shown in the view section. // 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, { '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), colRef: WEBHOOK_VIEW_FIELDS.map(colId => WEBHOOK_COLUMNS.find(r => r.colId === colId)!.id),
parentId: WEBHOOK_VIEW_FIELDS.map(() => 'vt_webhook_fs1'), parentId: WEBHOOK_VIEW_FIELDS.map(() => 'vt_webhook_fs1'),
parentPos: WEBHOOK_VIEW_FIELDS.map((_, i) => i), parentPos: WEBHOOK_VIEW_FIELDS.map((_, i) => i),

View File

@ -2,8 +2,8 @@ import {ActionGroup} from 'app/common/ActionGroup';
import {DocAction} from 'app/common/DocActions'; import {DocAction} from 'app/common/DocActions';
import {FilteredDocUsageSummary} from 'app/common/DocUsage'; import {FilteredDocUsageSummary} from 'app/common/DocUsage';
import {Product} from 'app/common/Features'; import {Product} from 'app/common/Features';
import {StringUnion} from 'app/common/StringUnion';
import {UserProfile} from 'app/common/LoginSessionAPI'; import {UserProfile} from 'app/common/LoginSessionAPI';
import {StringUnion} from 'app/common/StringUnion';
export const ValidEvent = StringUnion( export const ValidEvent = StringUnion(
'docListAction', 'docUserAction', 'docShutdown', 'docError', 'docListAction', 'docUserAction', 'docShutdown', 'docError',
@ -90,11 +90,17 @@ export interface CommDocUserAction extends CommMessageBase {
}; };
} }
export enum WebhookMessageType {
Update = 'webhookUpdate',
Overflow = 'webhookOverflowError'
}
export interface CommDocChatter extends CommMessageBase { export interface CommDocChatter extends CommMessageBase {
type: 'docChatter'; type: 'docChatter';
docFD: number; docFD: number;
data: { data: {
webhooks?: { webhooks?: {
type: WebhookMessageType,
// If present, something happened related to webhooks. // If present, something happened related to webhooks.
// Currently, we give no details, leaving it to client // Currently, we give no details, leaving it to client
// to call back for details if it cares. // to call back for details if it cares.

View File

@ -4,18 +4,18 @@ import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompt
import {BaseAPI, IOptions} from 'app/common/BaseAPI'; import {BaseAPI, IOptions} from 'app/common/BaseAPI';
import {BillingAPI, BillingAPIImpl} from 'app/common/BillingAPI'; import {BillingAPI, BillingAPIImpl} from 'app/common/BillingAPI';
import {BrowserSettings} from 'app/common/BrowserSettings'; import {BrowserSettings} from 'app/common/BrowserSettings';
import {ICustomWidget} from 'app/common/CustomWidget';
import {BulkColValues, TableColValues, TableRecordValue, TableRecordValues, UserAction} from 'app/common/DocActions'; import {BulkColValues, TableColValues, TableRecordValue, TableRecordValues, UserAction} from 'app/common/DocActions';
import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI'; import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
import {OrgUsageSummary} from 'app/common/DocUsage'; import {OrgUsageSummary} from 'app/common/DocUsage';
import {Product} from 'app/common/Features'; import {Product} from 'app/common/Features';
import {ICustomWidget} from 'app/common/CustomWidget';
import {isClient} from 'app/common/gristUrls'; import {isClient} from 'app/common/gristUrls';
import {encodeQueryParams} from 'app/common/gutil';
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI'; import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs'; import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {addCurrentOrgToPath} from 'app/common/urlUtils';
import {encodeQueryParams} from 'app/common/gutil';
import {WebhookFields, WebhookSubscribe, WebhookSummary, WebhookUpdate} from 'app/common/Triggers'; import {WebhookFields, WebhookSubscribe, WebhookSummary, WebhookUpdate} from 'app/common/Triggers';
import {addCurrentOrgToPath} from 'app/common/urlUtils';
import omitBy from 'lodash/omitBy'; import omitBy from 'lodash/omitBy';
@ -463,6 +463,7 @@ export interface DocAPI {
// Update webhook // Update webhook
updateWebhook(webhook: WebhookUpdate): Promise<void>; updateWebhook(webhook: WebhookUpdate): Promise<void>;
flushWebhooks(): Promise<void>; flushWebhooks(): Promise<void>;
flushWebhook(webhookId: string): Promise<void>;
getAssistance(params: AssistanceRequest): Promise<AssistanceResponse>; getAssistance(params: AssistanceRequest): Promise<AssistanceResponse>;
} }
@ -949,6 +950,12 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
}); });
} }
public async flushWebhook(id: string): Promise<void> {
await this.request(`${this._url}/webhooks/queue/${id}`, {
method: 'DELETE'
});
}
public async forceReload(): Promise<void> { public async forceReload(): Promise<void> {
await this.request(`${this._url}/force-reload`, { await this.request(`${this._url}/force-reload`, {
method: 'POST' method: 'POST'

View File

@ -35,6 +35,7 @@ import {
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {mapGetOrSet, MapWithTTL} from 'app/common/AsyncCreate'; import {mapGetOrSet, MapWithTTL} from 'app/common/AsyncCreate';
import {AttachmentColumns, gatherAttachmentIds, getAttachmentColumns} from 'app/common/AttachmentColumns'; import {AttachmentColumns, gatherAttachmentIds, getAttachmentColumns} from 'app/common/AttachmentColumns';
import {WebhookMessageType} from 'app/common/CommTypes';
import { import {
BulkAddRecord, BulkAddRecord,
BulkRemoveRecord, BulkRemoveRecord,
@ -129,8 +130,7 @@ import {createAttachmentsIndex, DocStorage, REMOVE_UNUSED_ATTACHMENTS_DELAY} fro
import {expandQuery} from './ExpandedQuery'; import {expandQuery} from './ExpandedQuery';
import {GranularAccess, GranularAccessForBundle} from './GranularAccess'; import {GranularAccess, GranularAccessForBundle} from './GranularAccess';
import {OnDemandActions} from './OnDemandActions'; import {OnDemandActions} from './OnDemandActions';
import {getLogMetaFromDocSession, getPubSubPrefix, getTelemetryMetaFromDocSession, import {getLogMetaFromDocSession, getPubSubPrefix, getTelemetryMetaFromDocSession, timeoutReached} from './serverUtils';
timeoutReached} from './serverUtils';
import {findOrAddAllEnvelope, Sharing} from './Sharing'; import {findOrAddAllEnvelope, Sharing} from './Sharing';
import cloneDeep = require('lodash/cloneDeep'); import cloneDeep = require('lodash/cloneDeep');
import flatten = require('lodash/flatten'); import flatten = require('lodash/flatten');
@ -1769,6 +1769,10 @@ export class ActiveDoc extends EventEmitter {
await this._triggers.clearWebhookQueue(); await this._triggers.clearWebhookQueue();
} }
public async clearSingleWebhookQueue(webhookId: string) {
await this._triggers.clearSingleWebhookQueue(webhookId);
}
/** /**
* Returns the list of outgoing webhook for a table in this document. * Returns the list of outgoing webhook for a table in this document.
*/ */
@ -1778,13 +1782,13 @@ export class ActiveDoc extends EventEmitter {
/** /**
* Send a message to clients connected to the document that something * Send a message to clients connected to the document that something
* webhook-related has happened (a change in configuration, or a * webhook-related has happened (a change in configuration, a delivery,
* delivery, or an error). There is room to give details in future, * or an error). It passes information about the type of event (currently data being updated in some way
* if that proves useful, but for now no details are needed. * or an OverflowError, i.e., too many events waiting to be sent). More data may be added when necessary.
*/ */
public async sendWebhookNotification() { public async sendWebhookNotification(type: WebhookMessageType = WebhookMessageType.Update) {
await this.docClients.broadcastDocMessage(null, 'docChatter', { await this.docClients.broadcastDocMessage(null, 'docChatter', {
webhooks: {}, webhooks: {type},
}); });
} }

View File

@ -763,6 +763,16 @@ export class DocWorkerApi {
// Clears a single webhook in the queue for this document.
this._app.delete('/api/docs/:docId/webhooks/queue/:webhookId', isOwner,
withDoc(async (activeDoc, req, res) => {
const webhookId = req.params.webhookId;
await activeDoc.clearSingleWebhookQueue(webhookId);
await activeDoc.sendWebhookNotification();
res.json({success: true});
})
);
// Lists all webhooks and their current status in the document. // Lists all webhooks and their current status in the document.
this._app.get('/api/docs/:docId/webhooks', isOwner, this._app.get('/api/docs/:docId/webhooks', isOwner,
withDoc(async (activeDoc, req, res) => { withDoc(async (activeDoc, req, res) => {

View File

@ -3,6 +3,7 @@ import {summarizeAction} from 'app/common/ActionSummarizer';
import {ActionSummary, TableDelta} from 'app/common/ActionSummary'; import {ActionSummary, TableDelta} from 'app/common/ActionSummary';
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {MapWithTTL} from 'app/common/AsyncCreate'; import {MapWithTTL} from 'app/common/AsyncCreate';
import {WebhookMessageType} from "app/common/CommTypes";
import {fromTableDataAction, RowRecord, TableColValues, TableDataAction} from 'app/common/DocActions'; import {fromTableDataAction, RowRecord, TableColValues, TableDataAction} from 'app/common/DocActions';
import {StringUnion} from 'app/common/StringUnion'; import {StringUnion} from 'app/common/StringUnion';
import {MetaRowRecord} from 'app/common/TableData'; import {MetaRowRecord} from 'app/common/TableData';
@ -234,7 +235,9 @@ export class DocTriggers {
// Prevent further document activity while the queue is too full. // Prevent further document activity while the queue is too full.
while (this._drainingQueue && !this._shuttingDown) { while (this._drainingQueue && !this._shuttingDown) {
await delayAbort(1000, this._loopAbort?.signal); const sendNotificationPromise = this._activeDoc.sendWebhookNotification(WebhookMessageType.Overflow);
const delayPromise = delayAbort(5000, this._loopAbort?.signal);
await Promise.all([sendNotificationPromise, delayPromise]);
} }
return summary; return summary;
@ -335,6 +338,36 @@ export class DocTriggers {
await this._stats.clear(); await this._stats.clear();
} }
public async clearSingleWebhookQueue(webhookId: string) {
// Make sure we are after start and in sync with redis.
if (this._getRedisQueuePromise) {
await this._getRedisQueuePromise;
}
// Clear in-memory queue for given webhook key.
let removed = 0;
for(let i=0; i< this._webHookEventQueue.length; i++){
if(this._webHookEventQueue[i].id == webhookId){
this._webHookEventQueue.splice(i, 1);
removed++;
}
}
// Notify the loop that it should restart.
this._loopAbort?.abort();
// If we have backup in redis, clear it also.
// NOTE: this is subject to a race condition, currently it is not possible, but any future modification probably
// will require some kind of locking over the queue (or a rewrite)
if (removed && this._redisClient) {
const multi = this._redisClient.multi();
multi.del(this._redisQueueKey);
// Re-add all the remaining events to the queue.
const strings = this._webHookEventQueue.map(e => JSON.stringify(e));
multi.rpush(this._redisQueueKey, ...strings);
await multi.execAsync();
}
await this._stats.clear();
}
// Converts a table to tableId by looking it up in _grist_Tables. // Converts a table to tableId by looking it up in _grist_Tables.
private _getTableId(rowId: number) { private _getTableId(rowId: number) {
const docData = this._activeDoc.docData; const docData = this._activeDoc.docData;

View File

@ -7,6 +7,7 @@ processes = []
APP_DOC_URL="https://{APP_NAME}.fly.dev" APP_DOC_URL="https://{APP_NAME}.fly.dev"
APP_HOME_URL="https://{APP_NAME}.fly.dev" APP_HOME_URL="https://{APP_NAME}.fly.dev"
APP_STATIC_URL="https://{APP_NAME}.fly.dev" APP_STATIC_URL="https://{APP_NAME}.fly.dev"
ALLOWED_WEBHOOK_DOMAINS="webhook.site"
GRIST_SINGLE_ORG="docs" GRIST_SINGLE_ORG="docs"
PORT = "8080" PORT = "8080"
FLY_DEPLOY_EXPIRATION = "{FLY_DEPLOY_EXPIRATION}" FLY_DEPLOY_EXPIRATION = "{FLY_DEPLOY_EXPIRATION}"

View File

@ -0,0 +1,117 @@
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 {server, setupTestSuite} from 'test/nbrowser/testUtils';
import {EnvironmentSnapshot} from 'test/server/testUtils';
import {WebhookFields} from "../../app/common/Triggers";
describe('WebhookOverflow', function () {
this.timeout(30000);
const cleanup = setupTestSuite();
let session: gu.Session;
let oldEnv: EnvironmentSnapshot;
let doc: DocCreationInfo;
let docApi: DocAPI;
before(async function () {
oldEnv = new EnvironmentSnapshot();
//host = new URL(server.getHost()).host;
process.env.ALLOWED_WEBHOOK_DOMAINS = '*';
process.env.GRIST_MAX_QUEUE_SIZE = '2';
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'}]],
]);
const webhookDetails: WebhookFields = {
url: 'https://localhost/WrongWebhook',
eventTypes: ["add", "update"],
enabled: true,
name: 'test webhook',
tableId: 'Table2',
};
await docApi.addWebhook(webhookDetails);
});
after(async function () {
oldEnv.restore();
await server.restart();
});
async function enterCellWithoutWaitingOnServer(...keys: string[]) {
const lastKey = keys[keys.length - 1];
if (![Key.ENTER, Key.TAB, Key.DELETE].includes(lastKey)) {
keys.push(Key.ENTER);
}
await driver.sendKeys(...keys);
}
it('should show a message when overflown', async function () {
await gu.openPage('Table2');
await gu.getCell('A', 1).click();
await gu.enterCell('123');
await gu.getCell('B', 1).click();
await enterCellWithoutWaitingOnServer('124');
const toast = await driver.wait(() => gu.getToasts(), 10000);
assert.include(toast, 'New changes are temporarily suspended. Webhooks queue overflowed.' +
' Please check webhooks settings, remove invalid webhooks, and clean the queue.\ngo to webhook settings');
});
it('message should disappear after clearing queue', async function () {
await openWebhookPageWithoutWaitForServer();
await driver.findContent('button', /Clear Queue/).click();
await gu.waitForServer();
await waitForOverflownMessageToDisappear();
const toast = await driver.wait(() => gu.getToasts());
assert.notInclude(toast, 'New changes are temporarily suspended. Webhooks queue overflowed.' +
' Please check webhooks settings, remove invalid webhooks, and clean the queue.\ngo to webhook settings');
});
});
async function waitForOverflownMessageToDisappear(maxWait = 12500) {
await driver.wait(async () => {
try {
for (;;) {
const toasts = await gu.getToasts();
const filteredToasts = toasts.find(t => t=='New changes are temporarily suspended. Webhooks queue overflowed.' +
' Please check webhooks settings, remove invalid webhooks, and clean the queue.\ngo to webhook settings');
if (!filteredToasts) {
return true;
}
}
} catch (e) {
return false;
}
}, maxWait, 'Overflown message did not disappear');
}
async function openWebhookPageWithoutWaitForServer() {
await 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();
}
export async function openAccountMenu() {
await driver.findWait('.test-dm-account', 1000).click();
// Since the AccountWidget loads orgs and the user data asynchronously, the menu
// can expand itself causing the click to land on a wrong button.
await driver.findWait('.test-site-switcher-org', 1000);
await driver.sleep(250); // There's still some jitter (scroll-bar? other user accounts?)
}
export async function openDocumentSettings() {
await openAccountMenu();
await driver.findContent('.grist-floating-menu a', 'Document Settings').click();
await gu.waitForUrl(/settings/, 5000);
}

View File

@ -1,11 +1,9 @@
import { DocCreationInfo } from 'app/common/DocListAPI'; import {DocCreationInfo} from 'app/common/DocListAPI';
import { DocAPI } from 'app/common/UserAPI'; import {DocAPI} from 'app/common/UserAPI';
import { assert, driver, Key } from 'mocha-webdriver'; import {assert, driver, Key} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils'; import * as gu from 'test/nbrowser/gristUtils';
import { setupTestSuite } from 'test/nbrowser/testUtils'; import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import { EnvironmentSnapshot } from 'test/server/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 () { describe('WebhookPage', function () {
this.timeout(60000); this.timeout(60000);