mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Adding UI for timing API
Summary: Adding new buttons to control the `timing` API and a way to view the results using virtual table features. Test Plan: Added new Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D4252
This commit is contained in:
parent
60423edc17
commit
a6ffa6096a
@ -1,6 +1,6 @@
|
|||||||
import {textarea} from 'app/client/ui/inputs';
|
import {textarea} from 'app/client/ui/inputs';
|
||||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
||||||
import {basicButton, basicButtonLink, textButton} from 'app/client/ui2018/buttons';
|
import {basicButton, basicButtonLink, primaryButtonLink, textButton} from 'app/client/ui2018/buttons';
|
||||||
import {cssLabel} from 'app/client/ui2018/checkbox';
|
import {cssLabel} from 'app/client/ui2018/checkbox';
|
||||||
import {colors, theme} from 'app/client/ui2018/cssVars';
|
import {colors, theme} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
@ -403,7 +403,7 @@ export const cssSmallLinkButton = styled(basicButtonLink, `
|
|||||||
min-height: 26px;
|
min-height: 26px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export const cssSmallButton = styled(basicButton, `
|
const textSmallButton = `
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@ -423,7 +423,10 @@ export const cssSmallButton = styled(basicButton, `
|
|||||||
background-color: #B8791B;
|
background-color: #B8791B;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
`);
|
`;
|
||||||
|
|
||||||
|
export const cssSmallButton = styled(basicButton, textSmallButton);
|
||||||
|
export const cssPrimarySmallLink = styled(primaryButtonLink, textSmallButton);
|
||||||
|
|
||||||
export const cssMarkdownRendered = styled('div', `
|
export const cssMarkdownRendered = styled('div', `
|
||||||
min-height: 1.5rem;
|
min-height: 1.5rem;
|
||||||
|
@ -79,6 +79,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
|
|||||||
BaseView.call(this, gristDoc, viewSectionModel, { isPreview, 'addNewRow': true });
|
BaseView.call(this, gristDoc, viewSectionModel, { isPreview, 'addNewRow': true });
|
||||||
|
|
||||||
this.viewSection = viewSectionModel;
|
this.viewSection = viewSectionModel;
|
||||||
|
this.isReadonly = this.gristDoc.isReadonly.get() || this.viewSection.isVirtual();
|
||||||
|
|
||||||
//--------------------------------------------------
|
//--------------------------------------------------
|
||||||
// Observables local to this view
|
// Observables local to this view
|
||||||
@ -390,7 +391,7 @@ GridView.gridCommands = {
|
|||||||
if (!action) { return; }
|
if (!action) { return; }
|
||||||
// if grist document is in readonly - simply change the value
|
// if grist document is in readonly - simply change the value
|
||||||
// without saving
|
// without saving
|
||||||
if (this.gristDoc.isReadonly.get()) {
|
if (this.isReadonly) {
|
||||||
this.viewSection.rawNumFrozen(action.numFrozen);
|
this.viewSection.rawNumFrozen(action.numFrozen);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1270,7 +1271,7 @@ GridView.prototype.buildDom = function() {
|
|||||||
const isEditingLabel = koUtil.withKoUtils(ko.pureComputed({
|
const isEditingLabel = koUtil.withKoUtils(ko.pureComputed({
|
||||||
read: () => {
|
read: () => {
|
||||||
const goodIndex = () => editIndex() === field._index();
|
const goodIndex = () => editIndex() === field._index();
|
||||||
const isReadonly = () => this.gristDoc.isReadonlyKo() || self.isPreview;
|
const isReadonly = () => this.isReadonly || self.isPreview;
|
||||||
const isSummary = () => Boolean(field.column().disableEditData());
|
const isSummary = () => Boolean(field.column().disableEditData());
|
||||||
return goodIndex() && !isReadonly() && !isSummary();
|
return goodIndex() && !isReadonly() && !isSummary();
|
||||||
},
|
},
|
||||||
@ -1335,7 +1336,7 @@ GridView.prototype.buildDom = function() {
|
|||||||
},
|
},
|
||||||
kd.style('width', field.widthPx),
|
kd.style('width', field.widthPx),
|
||||||
kd.style('borderRightWidth', v.borderWidthPx),
|
kd.style('borderRightWidth', v.borderWidthPx),
|
||||||
viewCommon.makeResizable(field.width, {shouldSave: !this.gristDoc.isReadonly.get()}),
|
viewCommon.makeResizable(field.width, {shouldSave: !this.isReadonly}),
|
||||||
kd.toggleClass('selected', () => ko.unwrap(this.isColSelected.at(field._index()))),
|
kd.toggleClass('selected', () => ko.unwrap(this.isColSelected.at(field._index()))),
|
||||||
dom.on('contextmenu', ev => {
|
dom.on('contextmenu', ev => {
|
||||||
// This is a little hack to position the menu the same way as with a click
|
// This is a little hack to position the menu the same way as with a click
|
||||||
@ -1382,7 +1383,7 @@ GridView.prototype.buildDom = function() {
|
|||||||
this._buildInsertColumnMenu({field}),
|
this._buildInsertColumnMenu({field}),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
this.isPreview ? null : kd.maybe(() => !this.gristDoc.isReadonlyKo(), () => (
|
this.isPreview ? null : kd.maybe(() => !this.isReadonly, () => (
|
||||||
this._modField = dom('div.column_name.mod-add-column.field',
|
this._modField = dom('div.column_name.mod-add-column.field',
|
||||||
'+',
|
'+',
|
||||||
kd.style("width", PLUS_WIDTH + 'px'),
|
kd.style("width", PLUS_WIDTH + 'px'),
|
||||||
@ -1933,7 +1934,7 @@ GridView.prototype._getColumnMenuOptions = function(copySelection) {
|
|||||||
numColumns: copySelection.fields.length,
|
numColumns: copySelection.fields.length,
|
||||||
numFrozen: this.viewSection.numFrozen.peek(),
|
numFrozen: this.viewSection.numFrozen.peek(),
|
||||||
disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()),
|
disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()),
|
||||||
isReadonly: this.gristDoc.isReadonly.get() || this.isPreview,
|
isReadonly: this.isReadonly || this.isPreview,
|
||||||
isRaw: this.viewSection.isRaw(),
|
isRaw: this.viewSection.isRaw(),
|
||||||
isFiltered: this.isFiltered(),
|
isFiltered: this.isFiltered(),
|
||||||
isFormula: calcFieldsCondition(copySelection.fields, f => f.column.peek().isRealFormula.peek()),
|
isFormula: calcFieldsCondition(copySelection.fields, f => f.column.peek().isRealFormula.peek()),
|
||||||
@ -1999,17 +2000,17 @@ GridView.prototype.cellContextMenu = function() {
|
|||||||
GridView.prototype._getCellContextMenuOptions = function() {
|
GridView.prototype._getCellContextMenuOptions = function() {
|
||||||
return {
|
return {
|
||||||
disableInsert: Boolean(
|
disableInsert: Boolean(
|
||||||
this.gristDoc.isReadonly.get() ||
|
this.isReadonly ||
|
||||||
this.viewSection.disableAddRemoveRows() ||
|
this.viewSection.disableAddRemoveRows() ||
|
||||||
this.tableModel.tableMetaRow.onDemand()
|
this.tableModel.tableMetaRow.onDemand()
|
||||||
),
|
),
|
||||||
disableDelete: Boolean(
|
disableDelete: Boolean(
|
||||||
this.gristDoc.isReadonly.get() ||
|
this.isReadonly ||
|
||||||
this.viewSection.disableAddRemoveRows() ||
|
this.viewSection.disableAddRemoveRows() ||
|
||||||
this.getSelection().onlyAddRowSelected()
|
this.getSelection().onlyAddRowSelected()
|
||||||
),
|
),
|
||||||
disableMakeHeadersFromRow: Boolean(
|
disableMakeHeadersFromRow: Boolean(
|
||||||
this.gristDoc.isReadonly.get() ||
|
this.isReadonly ||
|
||||||
this.getSelection().rowIds.length !== 1 ||
|
this.getSelection().rowIds.length !== 1 ||
|
||||||
this.getSelection().onlyAddRowSelected() ||
|
this.getSelection().onlyAddRowSelected() ||
|
||||||
this.viewSection.table().summarySourceTable() !== 0
|
this.viewSection.table().summarySourceTable() !== 0
|
||||||
|
@ -49,6 +49,7 @@ import {DocSettingsPage} from 'app/client/ui/DocumentSettings';
|
|||||||
import {isTourActive, isTourActiveObs} from "app/client/ui/OnBoardingPopups";
|
import {isTourActive, isTourActiveObs} from "app/client/ui/OnBoardingPopups";
|
||||||
import {DefaultPageWidget, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
import {DefaultPageWidget, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
|
||||||
import {linkFromId, NoLink, selectBy} from 'app/client/ui/selectBy';
|
import {linkFromId, NoLink, selectBy} from 'app/client/ui/selectBy';
|
||||||
|
import {TimingPage} from 'app/client/ui/TimingPage';
|
||||||
import {WebhookPage} from 'app/client/ui/WebhookPage';
|
import {WebhookPage} from 'app/client/ui/WebhookPage';
|
||||||
import {startWelcomeTour} from 'app/client/ui/WelcomeTour';
|
import {startWelcomeTour} from 'app/client/ui/WelcomeTour';
|
||||||
import {getTelemetryWidgetTypeFromPageWidget} from 'app/client/ui/widgetTypesMap';
|
import {getTelemetryWidgetTypeFromPageWidget} from 'app/client/ui/widgetTypesMap';
|
||||||
@ -196,6 +197,8 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
return this.docPageModel.appModel.api.getDocAPI(this.docPageModel.currentDocId.get()!);
|
return this.docPageModel.appModel.api.getDocAPI(this.docPageModel.currentDocId.get()!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isTimingOn = Observable.create(this, false);
|
||||||
|
|
||||||
private _actionLog: ActionLog;
|
private _actionLog: ActionLog;
|
||||||
private _undoStack: UndoStack;
|
private _undoStack: UndoStack;
|
||||||
private _lastOwnActionGroup: ActionGroupWithCursorPos | null = null;
|
private _lastOwnActionGroup: ActionGroupWithCursorPos | null = null;
|
||||||
@ -228,6 +231,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
console.log("RECEIVED DOC RESPONSE", openDocResponse);
|
console.log("RECEIVED DOC RESPONSE", openDocResponse);
|
||||||
|
this.isTimingOn.set(openDocResponse.isTimingOn);
|
||||||
this.docData = new DocData(this.docComm, openDocResponse.doc);
|
this.docData = new DocData(this.docComm, openDocResponse.doc);
|
||||||
this.docModel = new DocModel(this.docData, this.docPageModel);
|
this.docModel = new DocModel(this.docData, this.docPageModel);
|
||||||
this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm);
|
this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm);
|
||||||
@ -635,6 +639,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
content === 'data' ? dom.create(RawDataPage, this) :
|
content === 'data' ? dom.create(RawDataPage, this) :
|
||||||
content === 'settings' ? dom.create(DocSettingsPage, this) :
|
content === 'settings' ? dom.create(DocSettingsPage, this) :
|
||||||
content === 'webhook' ? dom.create(WebhookPage, this) :
|
content === 'webhook' ? dom.create(WebhookPage, this) :
|
||||||
|
content === 'timing' ? dom.create(TimingPage, this) :
|
||||||
content === 'GristDocTour' ? null :
|
content === 'GristDocTour' ? null :
|
||||||
[
|
[
|
||||||
dom.create((owner) => {
|
dom.create((owner) => {
|
||||||
@ -842,16 +847,20 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onDocChatter(message: CommDocChatter) {
|
public onDocChatter(message: CommDocChatter) {
|
||||||
if (!this.docComm.isActionFromThisDoc(message) ||
|
if (!this.docComm.isActionFromThisDoc(message)) {
|
||||||
!message.data.webhooks) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.data.webhooks.type == 'webhookOverflowError') {
|
|
||||||
this.trigger('webhookOverflowError',
|
if (message.data.webhooks) {
|
||||||
t('New changes are temporarily suspended. Webhooks queue overflowed.' +
|
if (message.data.webhooks.type == 'webhookOverflowError') {
|
||||||
' Please check webhooks settings, remove invalid webhooks, and clean the queue.'),);
|
this.trigger('webhookOverflowError',
|
||||||
} else {
|
t('New changes are temporarily suspended. Webhooks queue overflowed.' +
|
||||||
this.trigger('webhooks', message.data.webhooks);
|
' Please check webhooks settings, remove invalid webhooks, and clean the queue.'),);
|
||||||
|
} else {
|
||||||
|
this.trigger('webhooks', message.data.webhooks);
|
||||||
|
}
|
||||||
|
} else if (message.data.timing) {
|
||||||
|
this.isTimingOn.set(message.data.timing.status !== 'disabled');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,7 +22,9 @@ export class ClientColumnGetters implements ColumnGetters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getColGetter(colSpec: Sort.ColSpec): ColumnGetter | null {
|
public getColGetter(colSpec: Sort.ColSpec): ColumnGetter | null {
|
||||||
const rowModel = this._tableModel.docModel.columns.getRowModel(Sort.getColRef(colSpec));
|
const rowModel = this._tableModel.docModel.columns.getRowModel(
|
||||||
|
Sort.getColRef(colSpec) as number /* HACK: for virtual tables */
|
||||||
|
);
|
||||||
const colId = rowModel.colId();
|
const colId = rowModel.colId();
|
||||||
let getter: ColumnGetter|undefined = this._tableModel.tableData.getRowPropFunc(colId);
|
let getter: ColumnGetter|undefined = this._tableModel.tableData.getRowPropFunc(colId);
|
||||||
if (!getter) { return null; }
|
if (!getter) { return null; }
|
||||||
|
@ -675,7 +675,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
// with sharing.
|
// with sharing.
|
||||||
this.activeSortSpec = modelUtil.jsonObservable(this.activeSortJson, (obj: Sort.SortSpec|null) => {
|
this.activeSortSpec = modelUtil.jsonObservable(this.activeSortJson, (obj: Sort.SortSpec|null) => {
|
||||||
return (obj || []).filter((sortRef: Sort.ColSpec) => {
|
return (obj || []).filter((sortRef: Sort.ColSpec) => {
|
||||||
const colModel = docModel.columns.getRowModel(Sort.getColRef(sortRef));
|
const colModel = docModel.columns.getRowModel(Sort.getColRef(sortRef) as number /* HACK: for virtual tables */);
|
||||||
return !colModel._isDeleted() && colModel.getRowId();
|
return !colModel._isDeleted() && colModel.getRowId();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -120,7 +120,7 @@ const cssItemShort = styled('div', `
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const cssItemName = styled('div', `
|
const cssItemName = styled('div', `
|
||||||
width: 136px;
|
width: 150px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -148,6 +148,7 @@ const cssItemName = styled('div', `
|
|||||||
|
|
||||||
const cssItemDescription = styled('div', `
|
const cssItemDescription = styled('div', `
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
margin-bottom: -1px; /* aligns with the value */
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssItemValue = styled('div', `
|
const cssItemValue = styled('div', `
|
||||||
@ -173,7 +174,7 @@ const cssExpandedContentWrap = styled('div', `
|
|||||||
|
|
||||||
const cssExpandedContent = styled('div', `
|
const cssExpandedContent = styled('div', `
|
||||||
margin-left: 24px;
|
margin-left: 24px;
|
||||||
padding: 24px 0;
|
padding: 18px 0;
|
||||||
border-bottom: 1px solid ${theme.widgetBorder};
|
border-bottom: 1px solid ${theme.widgetBorder};
|
||||||
.${cssItem.className}:last-child & {
|
.${cssItem.className}:last-child & {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
* This module export a component for editing some document settings consisting of the timezone,
|
* This module export a component for editing some document settings consisting of the timezone,
|
||||||
* (new settings to be added here ...).
|
* (new settings to be added here ...).
|
||||||
*/
|
*/
|
||||||
import {cssSmallButton, cssSmallLinkButton} from 'app/client/components/Forms/styles';
|
import {cssPrimarySmallLink, cssSmallButton, cssSmallLinkButton} from 'app/client/components/Forms/styles';
|
||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {ACIndexImpl} from 'app/client/lib/ACIndex';
|
import {ACIndexImpl} from 'app/client/lib/ACIndex';
|
||||||
import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect';
|
import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect';
|
||||||
@ -14,21 +14,25 @@ import {urlState} from 'app/client/models/gristUrlState';
|
|||||||
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
||||||
import {AdminSection, AdminSectionItem} from 'app/client/ui/AdminPanelCss';
|
import {AdminSection, AdminSectionItem} from 'app/client/ui/AdminPanelCss';
|
||||||
import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
|
import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
|
||||||
import {colors, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
|
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||||
|
import {cssRadioCheckboxOptions, radioCheckboxOption} from 'app/client/ui2018/checkbox';
|
||||||
|
import {colors, mediaSmall, theme} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {cssLink} from 'app/client/ui2018/links';
|
import {cssLink} from 'app/client/ui2018/links';
|
||||||
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
import {select} from 'app/client/ui2018/menus';
|
import {select} from 'app/client/ui2018/menus';
|
||||||
import {confirmModal} from 'app/client/ui2018/modals';
|
import {confirmModal, cssModalButtons, cssModalTitle, cssSpinner, modal} from 'app/client/ui2018/modals';
|
||||||
import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
|
import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
|
||||||
import {buildTZAutocomplete} from 'app/client/widgets/TZAutocomplete';
|
import {buildTZAutocomplete} from 'app/client/widgets/TZAutocomplete';
|
||||||
import {EngineCode} from 'app/common/DocumentSettings';
|
import {EngineCode} from 'app/common/DocumentSettings';
|
||||||
import {commonUrls, GristLoadConfig} from 'app/common/gristUrls';
|
import {commonUrls, GristLoadConfig} from 'app/common/gristUrls';
|
||||||
import {propertyCompare} from 'app/common/gutil';
|
import {not, propertyCompare} from 'app/common/gutil';
|
||||||
import {getCurrency, locales} from 'app/common/Locales';
|
import {getCurrency, locales} from 'app/common/Locales';
|
||||||
import {Computed, Disposable, dom, fromKo, IDisposableOwner, styled} from 'grainjs';
|
import {Computed, Disposable, dom, fromKo, IDisposableOwner, makeTestId, Observable, styled} from 'grainjs';
|
||||||
import * as moment from 'moment-timezone';
|
import * as moment from 'moment-timezone';
|
||||||
|
|
||||||
const t = makeT('DocumentSettings');
|
const t = makeT('DocumentSettings');
|
||||||
|
const testId = makeTestId('test-settings-');
|
||||||
|
|
||||||
export class DocSettingsPage extends Disposable {
|
export class DocSettingsPage extends Disposable {
|
||||||
private _docInfo = this._gristDoc.docInfo;
|
private _docInfo = this._gristDoc.docInfo;
|
||||||
@ -53,6 +57,7 @@ export class DocSettingsPage extends Disposable {
|
|||||||
public buildDom() {
|
public buildDom() {
|
||||||
const canChangeEngine = getSupportedEngineChoices().length > 0;
|
const canChangeEngine = getSupportedEngineChoices().length > 0;
|
||||||
const docPageModel = this._gristDoc.docPageModel;
|
const docPageModel = this._gristDoc.docPageModel;
|
||||||
|
const isTimingOn = this._gristDoc.isTimingOn;
|
||||||
|
|
||||||
return cssContainer(
|
return cssContainer(
|
||||||
dom.create(AdminSection, t('Document Settings'), [
|
dom.create(AdminSection, t('Document Settings'), [
|
||||||
@ -80,26 +85,43 @@ export class DocSettingsPage extends Disposable {
|
|||||||
]),
|
]),
|
||||||
|
|
||||||
dom.create(AdminSection, t('Data Engine'), [
|
dom.create(AdminSection, t('Data Engine'), [
|
||||||
// dom.create(AdminSectionItem, {
|
dom.create(AdminSectionItem, {
|
||||||
// id: 'timings',
|
id: 'timings',
|
||||||
// name: t('Formula times'),
|
name: t('Formula timer'),
|
||||||
// description: t('Find slow formulas'),
|
description: dom('div',
|
||||||
// value: dom('div', t('Coming soon')),
|
dom.maybe(isTimingOn, () => cssRedText(t('Timing is on') + '...')),
|
||||||
// expandedContent: dom('div', t(
|
dom.maybe(not(isTimingOn), () => t('Find slow formulas')),
|
||||||
// 'Once you start timing, Grist will measure the time it takes to evaluate each formula. ' +
|
testId('timing-desc')
|
||||||
// 'This allows diagnosing which formulas are responsible for slow performance when a ' +
|
),
|
||||||
// 'document is first open, or when a document responds to changes.'
|
value: dom.domComputed(isTimingOn, (timingOn) => {
|
||||||
// )),
|
if (timingOn) {
|
||||||
// }),
|
return dom('div', {style: 'display: flex; gap: 4px'},
|
||||||
|
cssPrimarySmallLink(
|
||||||
|
t('Stop timing...'),
|
||||||
|
urlState().setHref({docPage: 'timing'}),
|
||||||
|
{target: '_blank'},
|
||||||
|
testId('timing-stop')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return cssSmallButton(t('Start timing'),
|
||||||
|
dom.on('click', this._startTiming.bind(this)),
|
||||||
|
testId('timing-start')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
expandedContent: dom('div', t(
|
||||||
|
'Once you start timing, Grist will measure the time it takes to evaluate each formula. ' +
|
||||||
|
'This allows diagnosing which formulas are responsible for slow performance when a ' +
|
||||||
|
'document is first opened, or when a document responds to changes.'
|
||||||
|
)),
|
||||||
|
}),
|
||||||
|
|
||||||
dom.create(AdminSectionItem, {
|
dom.create(AdminSectionItem, {
|
||||||
id: 'reload',
|
id: 'reload',
|
||||||
name: t('Reload'),
|
name: t('Reload'),
|
||||||
description: t('Hard reset of data engine'),
|
description: t('Hard reset of data engine'),
|
||||||
value: cssSmallButton('Reload data engine', dom.on('click', async () => {
|
value: cssSmallButton(t('Reload data engine'), dom.on('click', this._reloadEngine.bind(this, true))),
|
||||||
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
|
|
||||||
document.location.reload();
|
|
||||||
}))
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
canChangeEngine ? dom.create(AdminSectionItem, {
|
canChangeEngine ? dom.create(AdminSectionItem, {
|
||||||
@ -140,7 +162,7 @@ export class DocSettingsPage extends Disposable {
|
|||||||
cssWrap(t('Base doc URL: {{docApiUrl}}', {
|
cssWrap(t('Base doc URL: {{docApiUrl}}', {
|
||||||
docApiUrl: cssCopyLink(
|
docApiUrl: cssCopyLink(
|
||||||
{href: url},
|
{href: url},
|
||||||
url,
|
dom('span', url),
|
||||||
copyHandler(() => url, t("API URL copied to clipboard")),
|
copyHandler(() => url, t("API URL copied to clipboard")),
|
||||||
hoverTooltip(t('Copy to clipboard'), {
|
hoverTooltip(t('Copy to clipboard'), {
|
||||||
key: TOOLTIP_KEY,
|
key: TOOLTIP_KEY,
|
||||||
@ -170,10 +192,98 @@ export class DocSettingsPage extends Disposable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _reloadEngine(ask = true) {
|
||||||
|
const docPageModel = this._gristDoc.docPageModel;
|
||||||
|
const handler = async () => {
|
||||||
|
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
|
||||||
|
document.location.reload();
|
||||||
|
};
|
||||||
|
if (!ask) {
|
||||||
|
return handler();
|
||||||
|
}
|
||||||
|
confirmModal(t('Reload data engine?'), t('Reload'), handler, {
|
||||||
|
explanation: t(
|
||||||
|
'This will perform a hard reload of the data engine. This ' +
|
||||||
|
'may help if the data engine is stuck in an infinite loop, is ' +
|
||||||
|
'indefinitely processing the latest change, or has crashed. ' +
|
||||||
|
'No data will be lost, except possibly currently pending actions.'
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async _setEngine(val: EngineCode|undefined) {
|
private async _setEngine(val: EngineCode|undefined) {
|
||||||
confirmModal(t('Save and Reload'), t('Ok'), () => this._doSetEngine(val));
|
confirmModal(t('Save and Reload'), t('Ok'), () => this._doSetEngine(val));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _startTiming() {
|
||||||
|
const docPageModel = this._gristDoc.docPageModel;
|
||||||
|
modal((ctl, owner) => {
|
||||||
|
this.onDispose(() => ctl.close());
|
||||||
|
const selected = Observable.create<Option>(owner, Option.Adhoc);
|
||||||
|
const page = Observable.create<TimingModalPage>(owner, TimingModalPage.Start);
|
||||||
|
|
||||||
|
const startTiming = async () => {
|
||||||
|
if (selected.get() === Option.Reload) {
|
||||||
|
page.set(TimingModalPage.Spinner);
|
||||||
|
await this._gristDoc.docApi.startTiming();
|
||||||
|
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
|
||||||
|
ctl.close();
|
||||||
|
urlState().pushUrl({docPage: 'timing'}).catch(reportError);
|
||||||
|
} else {
|
||||||
|
await this._gristDoc.docApi.startTiming();
|
||||||
|
ctl.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPage = () => [
|
||||||
|
cssRadioCheckboxOptions(
|
||||||
|
dom.style('max-width', '400px'),
|
||||||
|
radioCheckboxOption(selected, Option.Adhoc, dom('div',
|
||||||
|
dom('div',
|
||||||
|
dom('strong', t('Start timing')),
|
||||||
|
),
|
||||||
|
dom('div',
|
||||||
|
dom.style('margin-top', '8px'),
|
||||||
|
dom('span', t('You can make changes to the document, then stop timing to see the results.'))
|
||||||
|
),
|
||||||
|
testId('timing-modal-option-adhoc'),
|
||||||
|
)),
|
||||||
|
radioCheckboxOption(selected, Option.Reload, dom('div',
|
||||||
|
dom('div',
|
||||||
|
dom('strong', t('Time reload')),
|
||||||
|
),
|
||||||
|
dom('div',
|
||||||
|
dom.style('margin-top', '8px'),
|
||||||
|
dom('span', t('Force reload the document while timing formulas, and show the result.'))
|
||||||
|
),
|
||||||
|
testId('timing-modal-option-reload'),
|
||||||
|
))
|
||||||
|
),
|
||||||
|
cssModalButtons(
|
||||||
|
bigPrimaryButton(t(`Start timing`),
|
||||||
|
dom.on('click', startTiming),
|
||||||
|
testId('timing-modal-confirm'),
|
||||||
|
),
|
||||||
|
bigBasicButton(t('Cancel'), dom.on('click', () => ctl.close()), testId('timing-modal-cancel')),
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
const spinnerPage = () => [
|
||||||
|
cssSpinner(
|
||||||
|
loadingSpinner(),
|
||||||
|
testId('timing-modal-spinner'),
|
||||||
|
dom.style('width', 'fit-content')
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
cssModalTitle(t(`Formula timer`)),
|
||||||
|
dom.domComputed(page, (p) => p === TimingModalPage.Start ? startPage() : spinnerPage()),
|
||||||
|
testId('timing-modal'),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async _doSetEngine(val: EngineCode|undefined) {
|
private async _doSetEngine(val: EngineCode|undefined) {
|
||||||
const docPageModel = this._gristDoc.docPageModel;
|
const docPageModel = this._gristDoc.docPageModel;
|
||||||
if (this._engine.get() !== val) {
|
if (this._engine.get() !== val) {
|
||||||
@ -183,6 +293,8 @@ export class DocSettingsPage extends Disposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function getApiConsoleLink(docPageModel: DocPageModel) {
|
function getApiConsoleLink(docPageModel: DocPageModel) {
|
||||||
const url = new URL(location.href);
|
const url = new URL(location.href);
|
||||||
url.pathname = '/apiconsole';
|
url.pathname = '/apiconsole';
|
||||||
@ -231,6 +343,7 @@ const cssContainer = styled('div', `
|
|||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 32px 64px 24px 64px;
|
padding: 32px 64px 24px 64px;
|
||||||
|
color: ${theme.text};
|
||||||
@media ${mediaSmall} {
|
@media ${mediaSmall} {
|
||||||
& {
|
& {
|
||||||
padding: 32px 24px 24px 24px;
|
padding: 32px 24px 24px 24px;
|
||||||
@ -333,6 +446,29 @@ function clickToSelect() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum for the different pages of the timing modal.
|
||||||
|
*/
|
||||||
|
enum TimingModalPage {
|
||||||
|
Start, // The initial page with options to start timing.
|
||||||
|
Spinner, // The page with a spinner while we are starting timing and reloading the document.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum for the different options in the timing modal.
|
||||||
|
*/
|
||||||
|
enum Option {
|
||||||
|
/**
|
||||||
|
* Start timing and immediately forces a reload of the document and waits for the
|
||||||
|
* document to be loaded, to show the results.
|
||||||
|
*/
|
||||||
|
Reload,
|
||||||
|
/**
|
||||||
|
* Just starts the timing, without reloading the document.
|
||||||
|
*/
|
||||||
|
Adhoc,
|
||||||
|
}
|
||||||
|
|
||||||
// A version that is not underlined, and on hover mouse pointer indicates that copy is available
|
// A version that is not underlined, and on hover mouse pointer indicates that copy is available
|
||||||
const cssCopyLink = styled(cssLink, `
|
const cssCopyLink = styled(cssLink, `
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
@ -364,3 +500,7 @@ const cssWrap = styled('p', `
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssRedText = styled('span', `
|
||||||
|
color: ${theme.errorText};
|
||||||
|
`);
|
||||||
|
252
app/client/ui/TimingPage.ts
Normal file
252
app/client/ui/TimingPage.ts
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
import BaseView = require('app/client/components/BaseView');
|
||||||
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
|
import {ViewSectionHelper} from 'app/client/components/ViewLayout';
|
||||||
|
import {makeT} from 'app/client/lib/localization';
|
||||||
|
import {IEdit, IExternalTable, VirtualTable} from 'app/client/models/VirtualTable';
|
||||||
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
|
import {docListHeader} from 'app/client/ui/DocMenuCss';
|
||||||
|
import {isNarrowScreenObs, mediaSmall} from 'app/client/ui2018/cssVars';
|
||||||
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
|
import {FormulaTimingInfo} from 'app/common/ActiveDocAPI';
|
||||||
|
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||||
|
import {
|
||||||
|
DocAction,
|
||||||
|
getColValues,
|
||||||
|
TableDataAction} from 'app/common/DocActions';
|
||||||
|
import {VirtualId} from 'app/common/SortSpec';
|
||||||
|
import {not} from 'app/common/gutil';
|
||||||
|
import {Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
|
||||||
|
import omit = require('lodash/omit');
|
||||||
|
import range = require('lodash/range');
|
||||||
|
|
||||||
|
const t = makeT('TimingPage');
|
||||||
|
const testId = makeTestId('test-timing-page-');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of columns for a virtual table about formula timings.
|
||||||
|
*/
|
||||||
|
const COLUMNS = [
|
||||||
|
{
|
||||||
|
id: VirtualId(),
|
||||||
|
colId: 'tableId',
|
||||||
|
type: 'Text',
|
||||||
|
label: t('Table ID'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: VirtualId(),
|
||||||
|
colId: 'colId',
|
||||||
|
type: 'Text',
|
||||||
|
label: t('Column ID'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: VirtualId(),
|
||||||
|
colId: 'sum',
|
||||||
|
type: 'Numeric',
|
||||||
|
label: t('Total Time (s)')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: VirtualId(),
|
||||||
|
colId: 'calls',
|
||||||
|
type: 'Numeric',
|
||||||
|
label: t('Number of Calls')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: VirtualId(),
|
||||||
|
colId: 'average',
|
||||||
|
type: 'Numeric',
|
||||||
|
label: t('Average Time (s)')
|
||||||
|
},
|
||||||
|
// max time
|
||||||
|
{
|
||||||
|
id: VirtualId(),
|
||||||
|
colId: 'max',
|
||||||
|
type: 'Numeric',
|
||||||
|
label: t('Max Time (s)')
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface TimingRecord {
|
||||||
|
tableId: string;
|
||||||
|
colId: string;
|
||||||
|
sum: number;
|
||||||
|
calls: number;
|
||||||
|
average: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIRTUAL_SECTION_ID = VirtualId();
|
||||||
|
const VIRTUAL_TABLE_ID = VirtualId();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout of fields in a view, with a specific ordering.
|
||||||
|
*/
|
||||||
|
const FIELDS: Array<(typeof COLUMNS)[number]['colId']> = [
|
||||||
|
'tableId', 'colId', 'sum', 'calls', 'average', 'max'
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
class TimingExternalTable extends Disposable implements IExternalTable {
|
||||||
|
public name = 'GristHidden_TimingTable';
|
||||||
|
public initialActions = _prepareInitialActions(this.name);
|
||||||
|
public saveableFields = [];
|
||||||
|
|
||||||
|
public constructor(private _initialData: FormulaTimingInfo[]) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchAll(): Promise<TableDataAction> {
|
||||||
|
const timingInfo = this._initialData;
|
||||||
|
console.debug('Timing info:', timingInfo);
|
||||||
|
const data = timingInfo || [];
|
||||||
|
const indicies = range(data.length).map(i => i + 1);
|
||||||
|
return ['TableData', this.name, indicies,
|
||||||
|
getColValues(indicies.map(rowId => _mapModelValues(rowId, data[rowId - 1])))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not used.
|
||||||
|
public async beforeEdit(editor: IEdit) {}
|
||||||
|
public async afterEdit(editor: IEdit) {}
|
||||||
|
public async afterAnySchemaChange(editor: IEdit) {}
|
||||||
|
public async sync(editor: IEdit): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimingPage extends DisposableWithEvents {
|
||||||
|
private _data: Observable<FormulaTimingInfo[]|null> = Observable.create(this, null);
|
||||||
|
|
||||||
|
constructor(private _gristDoc: GristDoc) {
|
||||||
|
super();
|
||||||
|
if (this._gristDoc.isTimingOn.get() === false) {
|
||||||
|
// Just redirect back to the settings page.
|
||||||
|
this._openSettings();
|
||||||
|
} else {
|
||||||
|
this._start().catch(ex => {
|
||||||
|
this._openSettings();
|
||||||
|
reportError(ex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public buildDom() {
|
||||||
|
return cssContainer(
|
||||||
|
dom.maybe(this._data, () =>
|
||||||
|
dom('div', {style: 'display: flex; justify-content: space-between; align-items: baseline'},
|
||||||
|
cssHeader(t('Formula timer')),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
dom.maybeOwned(this._data, (owner) => {
|
||||||
|
const viewSectionModel = this._gristDoc.docModel.viewSections.getRowModel(VIRTUAL_SECTION_ID as any);
|
||||||
|
ViewSectionHelper.create(owner, this._gristDoc, viewSectionModel);
|
||||||
|
return dom.maybe(use => use(viewSectionModel.viewInstance), (view: BaseView) =>
|
||||||
|
dom('div.active_section.view_data_pane_container.flexvbox', view.viewPane,
|
||||||
|
dom.maybe(use => !use(isNarrowScreenObs()), () => view.selectionSummary?.buildDom()),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
dom.maybe(not(this._data), () => cssLoaderScreen(
|
||||||
|
loadingSpinner(),
|
||||||
|
dom('div', t('Loading timing data. Don\'t close this tab.')),
|
||||||
|
testId('spinner'),
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _openSettings() {
|
||||||
|
urlState().pushUrl({docPage: 'settings'}).catch(reportError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _start() {
|
||||||
|
const docApi = this._gristDoc.docPageModel.appModel.api.getDocAPI(this._gristDoc.docId());
|
||||||
|
|
||||||
|
// Get the data from the server (and wait for the engine to calculate everything if it hasn't already).
|
||||||
|
const data = await docApi.stopTiming();
|
||||||
|
if (this.isDisposed()) { return; }
|
||||||
|
|
||||||
|
// And wire up the UI.
|
||||||
|
const ext = this.autoDispose(new TimingExternalTable(data));
|
||||||
|
new VirtualTable(this, this._gristDoc, ext);
|
||||||
|
this._data.set(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// See the WebhookPage for more details on how this works.
|
||||||
|
function _prepareInitialActions(tableId: string): DocAction[] {
|
||||||
|
return [[
|
||||||
|
// Add the virtual table.
|
||||||
|
'AddTable', tableId,
|
||||||
|
COLUMNS.map(col => ({
|
||||||
|
isFormula: true,
|
||||||
|
type: 'Any',
|
||||||
|
formula: '',
|
||||||
|
id: col.colId
|
||||||
|
}))
|
||||||
|
], [
|
||||||
|
// Add an entry for the virtual table.
|
||||||
|
'AddRecord', '_grist_Tables', VIRTUAL_TABLE_ID as any, {tableId, primaryViewId: 0},
|
||||||
|
], [
|
||||||
|
// Add entries for the columns of the virtual table.
|
||||||
|
'BulkAddRecord', '_grist_Tables_column',
|
||||||
|
COLUMNS.map(col => col.id) as any, getColValues(COLUMNS.map(rec =>
|
||||||
|
Object.assign({
|
||||||
|
isFormula: false,
|
||||||
|
formula: '',
|
||||||
|
widgetOptions: '',
|
||||||
|
parentId: VIRTUAL_TABLE_ID as any,
|
||||||
|
}, omit(rec, ['id']) as any))),
|
||||||
|
], [
|
||||||
|
// Add a view section.
|
||||||
|
'AddRecord', '_grist_Views_section', VIRTUAL_SECTION_ID as any,
|
||||||
|
{
|
||||||
|
tableRef: VIRTUAL_TABLE_ID, parentKey: 'record',
|
||||||
|
title: 'Timing', layout: 'vertical', showHeader: true,
|
||||||
|
borderWidth: 1, defaultWidth: 100,
|
||||||
|
}
|
||||||
|
], [
|
||||||
|
// List the fields shown in the view section.
|
||||||
|
'BulkAddRecord', '_grist_Views_section_field', FIELDS.map(VirtualId.bind(null, undefined)) as any, {
|
||||||
|
colRef: FIELDS.map(colId => COLUMNS.find(r => r.colId === colId)!.id),
|
||||||
|
parentId: FIELDS.map(() => VIRTUAL_SECTION_ID),
|
||||||
|
parentPos: FIELDS.map((_, i) => i),
|
||||||
|
}
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// See the WebhookPage for more details on how this works.
|
||||||
|
function _mapModelValues(rowId: number, model: FormulaTimingInfo): Partial<TimingRecord & {id: number}> {
|
||||||
|
return {
|
||||||
|
id: rowId,
|
||||||
|
tableId: model.tableId,
|
||||||
|
colId: model.colId,
|
||||||
|
sum: model.sum,
|
||||||
|
calls: model.count,
|
||||||
|
average: model.average,
|
||||||
|
max: model.max,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssHeader = styled(docListHeader, `
|
||||||
|
margin-bottom: 18px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssLoaderScreen = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
`);
|
@ -16,6 +16,7 @@ import {
|
|||||||
TableDataAction,
|
TableDataAction,
|
||||||
UserAction
|
UserAction
|
||||||
} from 'app/common/DocActions';
|
} from 'app/common/DocActions';
|
||||||
|
import {VirtualId} from 'app/common/SortSpec';
|
||||||
import {WebhookSummary} from 'app/common/Triggers';
|
import {WebhookSummary} from 'app/common/Triggers';
|
||||||
import {DocAPI} from 'app/common/UserAPI';
|
import {DocAPI} from 'app/common/UserAPI';
|
||||||
import {GristObjCode, RowRecord} from 'app/plugin/GristData';
|
import {GristObjCode, RowRecord} from 'app/plugin/GristData';
|
||||||
@ -28,13 +29,15 @@ import without = require('lodash/without');
|
|||||||
|
|
||||||
const t = makeT('WebhookPage');
|
const t = makeT('WebhookPage');
|
||||||
|
|
||||||
|
const TABLE_COLUMN_ROW_ID = VirtualId();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list of columns for a virtual table about webhooks.
|
* A list of columns for a virtual table about webhooks.
|
||||||
* The ids need to be strings.
|
* The ids need to be strings.
|
||||||
*/
|
*/
|
||||||
const WEBHOOK_COLUMNS = [
|
const WEBHOOK_COLUMNS = [
|
||||||
{
|
{
|
||||||
id: 'vt_webhook_fc1',
|
id: TABLE_COLUMN_ROW_ID,
|
||||||
colId: 'tableId',
|
colId: 'tableId',
|
||||||
type: 'Choice',
|
type: 'Choice',
|
||||||
label: t('Table'),
|
label: t('Table'),
|
||||||
@ -42,13 +45,13 @@ const WEBHOOK_COLUMNS = [
|
|||||||
// on the user tables in the document.
|
// on the user tables in the document.
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vt_webhook_fc2',
|
id: VirtualId(),
|
||||||
colId: 'url',
|
colId: 'url',
|
||||||
type: 'Text',
|
type: 'Text',
|
||||||
label: t('URL'),
|
label: t('URL'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vt_webhook_fc3',
|
id: VirtualId(),
|
||||||
colId: 'eventTypes',
|
colId: 'eventTypes',
|
||||||
type: 'ChoiceList',
|
type: 'ChoiceList',
|
||||||
label: t('Event Types'),
|
label: t('Event Types'),
|
||||||
@ -60,13 +63,13 @@ const WEBHOOK_COLUMNS = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vt_webhook_fc10',
|
id: VirtualId(),
|
||||||
colId: 'watchedColIdsText',
|
colId: 'watchedColIdsText',
|
||||||
type: 'Text',
|
type: 'Text',
|
||||||
label: t('Filter for changes in these columns (semicolon-separated ids)'),
|
label: t('Filter for changes in these columns (semicolon-separated ids)'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vt_webhook_fc4',
|
id: VirtualId(),
|
||||||
colId: 'enabled',
|
colId: 'enabled',
|
||||||
type: 'Bool',
|
type: 'Bool',
|
||||||
label: t('Enabled'),
|
label: t('Enabled'),
|
||||||
@ -75,31 +78,31 @@ const WEBHOOK_COLUMNS = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vt_webhook_fc5',
|
id: VirtualId(),
|
||||||
colId: 'isReadyColumn',
|
colId: 'isReadyColumn',
|
||||||
type: 'Text',
|
type: 'Text',
|
||||||
label: t('Ready Column'),
|
label: t('Ready Column'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vt_webhook_fc6',
|
id: VirtualId(),
|
||||||
colId: 'webhookId',
|
colId: 'webhookId',
|
||||||
type: 'Text',
|
type: 'Text',
|
||||||
label: t('Webhook Id'),
|
label: t('Webhook Id'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vt_webhook_fc7',
|
id: VirtualId(),
|
||||||
colId: 'name',
|
colId: 'name',
|
||||||
type: 'Text',
|
type: 'Text',
|
||||||
label: t('Name'),
|
label: t('Name'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vt_webhook_fc8',
|
id: VirtualId(),
|
||||||
colId: 'memo',
|
colId: 'memo',
|
||||||
type: 'Text',
|
type: 'Text',
|
||||||
label: t('Memo'),
|
label: t('Memo'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vt_webhook_fc9',
|
id: VirtualId(),
|
||||||
colId: 'status',
|
colId: 'status',
|
||||||
type: 'Text',
|
type: 'Text',
|
||||||
label: t('Status'),
|
label: t('Status'),
|
||||||
@ -264,7 +267,7 @@ class WebhookExternalTable implements IExternalTable {
|
|||||||
// Grist doesn't have a good way to handle contingent choices.
|
// Grist doesn't have a good way to handle contingent choices.
|
||||||
const choices = editor.gristDoc.docModel.visibleTables.all().map(tableRec => tableRec.tableId());
|
const choices = editor.gristDoc.docModel.visibleTables.all().map(tableRec => tableRec.tableId());
|
||||||
editor.gristDoc.docData.receiveAction([
|
editor.gristDoc.docData.receiveAction([
|
||||||
'UpdateRecord', '_grist_Tables_column', 'vt_webhook_fc1' as any, {
|
'UpdateRecord', '_grist_Tables_column', TABLE_COLUMN_ROW_ID as any, {
|
||||||
widgetOptions: JSON.stringify({
|
widgetOptions: JSON.stringify({
|
||||||
widget: 'TextBox',
|
widget: 'TextBox',
|
||||||
alignment: 'left',
|
alignment: 'left',
|
||||||
|
@ -672,7 +672,7 @@ const cssModalBacker = styled('div', `
|
|||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssSpinner = styled('div', `
|
export const cssSpinner = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
|
@ -295,7 +295,7 @@ export interface TimingInfo {
|
|||||||
/**
|
/**
|
||||||
* Total time spend evaluating a formula.
|
* Total time spend evaluating a formula.
|
||||||
*/
|
*/
|
||||||
total: number;
|
sum: number;
|
||||||
/**
|
/**
|
||||||
* Number of times the formula was evaluated (for all rows).
|
* Number of times the formula was evaluated (for all rows).
|
||||||
*/
|
*/
|
||||||
@ -320,9 +320,10 @@ export interface FormulaTimingInfo extends TimingInfo {
|
|||||||
*/
|
*/
|
||||||
export interface TimingStatus {
|
export interface TimingStatus {
|
||||||
/**
|
/**
|
||||||
* If true, timing info is being collected.
|
* If disabled then 'disabled', else 'active' or 'pending'. Pending means that the engine is busy
|
||||||
|
* and can't respond to confirm the status (but it used to be active before that).
|
||||||
*/
|
*/
|
||||||
status: boolean;
|
status: 'active'|'pending'|'disabled';
|
||||||
/**
|
/**
|
||||||
* Will be undefined if we can't get the timing info (e.g. if the document is locked by other call).
|
* Will be undefined if we can't get the timing info (e.g. if the document is locked by other call).
|
||||||
* Otherwise, contains the intermediate results gathered so far.
|
* Otherwise, contains the intermediate results gathered so far.
|
||||||
|
@ -107,6 +107,9 @@ export interface CommDocChatter extends CommMessageBase {
|
|||||||
},
|
},
|
||||||
// This could also be a fine place to send updated info
|
// This could also be a fine place to send updated info
|
||||||
// about other users of the document.
|
// about other users of the document.
|
||||||
|
timing?: {
|
||||||
|
status: 'active'|'disabled';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,6 +74,7 @@ export interface OpenLocalDocResult {
|
|||||||
clientId: string; // the docFD is meaningful only in the context of this session
|
clientId: string; // the docFD is meaningful only in the context of this session
|
||||||
doc: {[tableId: string]: TableDataAction};
|
doc: {[tableId: string]: TableDataAction};
|
||||||
log: MinimalActionGroup[];
|
log: MinimalActionGroup[];
|
||||||
|
isTimingOn: boolean;
|
||||||
recoveryMode?: boolean;
|
recoveryMode?: boolean;
|
||||||
userOverride?: UserOverride;
|
userOverride?: UserOverride;
|
||||||
docUsage?: FilteredDocUsageSummary;
|
docUsage?: FilteredDocUsageSummary;
|
||||||
|
@ -23,16 +23,17 @@ export namespace Sort {
|
|||||||
* Object base representation for column expression.
|
* Object base representation for column expression.
|
||||||
*/
|
*/
|
||||||
export interface ColSpecDetails {
|
export interface ColSpecDetails {
|
||||||
colRef: number;
|
colRef: ColRef;
|
||||||
direction: Direction;
|
direction: Direction;
|
||||||
orderByChoice?: boolean;
|
orderByChoice?: boolean;
|
||||||
emptyLast?: boolean;
|
emptyLast?: boolean;
|
||||||
naturalSort?: boolean;
|
naturalSort?: boolean;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Column expression type.
|
* Column expression type. Either number, an object, or virtual id string _vid\d+
|
||||||
*/
|
*/
|
||||||
export type ColSpec = number | string;
|
export type ColSpec = number | string;
|
||||||
|
export type ColRef = number | string;
|
||||||
/**
|
/**
|
||||||
* Sort expression type, for example [1,-2, '3:emptyLast', '-4:orderByChoice']
|
* Sort expression type, for example [1,-2, '3:emptyLast', '-4:orderByChoice']
|
||||||
*/
|
*/
|
||||||
@ -75,7 +76,7 @@ export namespace Sort {
|
|||||||
tail.push("orderByChoice");
|
tail.push("orderByChoice");
|
||||||
}
|
}
|
||||||
if (!tail.length) {
|
if (!tail.length) {
|
||||||
return +head;
|
return maybeNumber(head);
|
||||||
}
|
}
|
||||||
return head + (tail.length ? OPTION_SEPARATOR : "") + tail.join(FLAG_SEPARATOR);
|
return head + (tail.length ? OPTION_SEPARATOR : "") + tail.join(FLAG_SEPARATOR);
|
||||||
}
|
}
|
||||||
@ -92,21 +93,33 @@ export namespace Sort {
|
|||||||
: parseColSpec(colSpec);
|
: parseColSpec(colSpec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maybeNumber(colRef: string): ColRef {
|
||||||
|
const num = parseInt(colRef, 10);
|
||||||
|
return isNaN(num) ? colRef : num;
|
||||||
|
}
|
||||||
|
|
||||||
function parseColSpec(colString: string): ColSpecDetails {
|
function parseColSpec(colString: string): ColSpecDetails {
|
||||||
const REGEX = /^(-)?(\d+)(:([\w\d;]+))?$/;
|
if (!colString) {
|
||||||
|
throw new Error("Empty column expression");
|
||||||
|
}
|
||||||
|
const REGEX = /^(?<sign>-)?(?<colRef>(_vid)?(\d+))(:(?<flag>[\w\d;]+))?$/;
|
||||||
const match = colString.match(REGEX);
|
const match = colString.match(REGEX);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error("Error parsing sort expression " + colString);
|
throw new Error("Error parsing sort expression " + colString);
|
||||||
}
|
}
|
||||||
const [, sign, colRef, , flag] = match;
|
const {sign, colRef, flag} = match.groups || {};
|
||||||
const flags = flag?.split(";");
|
const flags = flag?.split(";");
|
||||||
return {
|
return onlyDefined({
|
||||||
colRef: +colRef,
|
colRef: maybeNumber(colRef),
|
||||||
direction: sign === "-" ? DESC : ASC,
|
direction: sign === "-" ? DESC : ASC,
|
||||||
orderByChoice: flags?.includes("orderByChoice"),
|
orderByChoice: flags?.includes("orderByChoice"),
|
||||||
emptyLast: flags?.includes("emptyLast"),
|
emptyLast: flags?.includes("emptyLast"),
|
||||||
naturalSort: flags?.includes("naturalSort"),
|
naturalSort: flags?.includes("naturalSort"),
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onlyDefined<T extends Record<string, any>>(obj: T): T{
|
||||||
|
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -138,17 +151,26 @@ export namespace Sort {
|
|||||||
* Converts column expression order.
|
* Converts column expression order.
|
||||||
*/
|
*/
|
||||||
export function setColDirection(colSpec: ColSpec, dir: Direction): ColSpec {
|
export function setColDirection(colSpec: ColSpec, dir: Direction): ColSpec {
|
||||||
if (typeof colSpec === "number") {
|
if (typeof colSpec == "number") {
|
||||||
return Math.abs(colSpec) * dir;
|
return Math.abs(colSpec) * dir;
|
||||||
|
} else if (colSpec.startsWith(VirtualId.PREFIX)) {
|
||||||
|
return dir === DESC ? `-${colSpec}` : colSpec;
|
||||||
|
} else if (colSpec.startsWith(`-${VirtualId.PREFIX}`)) {
|
||||||
|
return dir === ASC ? colSpec.slice(1) : colSpec;
|
||||||
|
} else {
|
||||||
|
return detailsToSpec({ ...parseColSpec(colSpec), direction: dir });
|
||||||
}
|
}
|
||||||
return detailsToSpec({ ...parseColSpec(colSpec), direction: dir });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates simple column expression.
|
* Creates simple column expression.
|
||||||
*/
|
*/
|
||||||
export function createColSpec(colRef: number, dir: Direction): ColSpec {
|
export function createColSpec(colRef: ColRef, dir: Direction): ColSpec {
|
||||||
return colRef * dir;
|
if (typeof colRef === "number") {
|
||||||
|
return colRef * dir;
|
||||||
|
} else {
|
||||||
|
return dir === ASC ? colRef : `-${colRef}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -187,7 +209,7 @@ export namespace Sort {
|
|||||||
/**
|
/**
|
||||||
* Swaps column id in column expression. Primary use for display columns.
|
* Swaps column id in column expression. Primary use for display columns.
|
||||||
*/
|
*/
|
||||||
export function swapColRef(colSpec: ColSpec, colRef: number): ColSpec {
|
export function swapColRef(colSpec: ColSpec, colRef: ColRef): ColSpec {
|
||||||
if (typeof colSpec === "number") {
|
if (typeof colSpec === "number") {
|
||||||
return colSpec >= 0 ? colRef : -colRef;
|
return colSpec >= 0 ? colRef : -colRef;
|
||||||
}
|
}
|
||||||
@ -220,7 +242,7 @@ export namespace Sort {
|
|||||||
* @param colRef Column id to remove
|
* @param colRef Column id to remove
|
||||||
* @param newSpec New column sort options to put in place of the old one.
|
* @param newSpec New column sort options to put in place of the old one.
|
||||||
*/
|
*/
|
||||||
export function replace(sortSpec: SortSpec, colRef: number, newSpec: ColSpec | ColSpecDetails): SortSpec {
|
export function replace(sortSpec: SortSpec, colRef: ColRef, newSpec: ColSpec | ColSpecDetails): SortSpec {
|
||||||
const index = findColIndex(sortSpec, colRef);
|
const index = findColIndex(sortSpec, colRef);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
const updated = sortSpec.slice();
|
const updated = sortSpec.slice();
|
||||||
@ -322,3 +344,26 @@ export namespace Sort {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _virtualIdCounter = 1;
|
||||||
|
const _virtualSymbols = new Map<string, string>();
|
||||||
|
/**
|
||||||
|
* Creates a virtual id for virtual tables. Can remember some generated ids if called with a
|
||||||
|
* name (this feature used only in tests for now).
|
||||||
|
*
|
||||||
|
* The resulting id looks like _vid\d+.
|
||||||
|
*/
|
||||||
|
export function VirtualId(symbol = '') {
|
||||||
|
if (symbol) {
|
||||||
|
if (!_virtualSymbols.has(symbol)) {
|
||||||
|
const generated = `${VirtualId.PREFIX}${_virtualIdCounter++}`;
|
||||||
|
_virtualSymbols.set(symbol, generated);
|
||||||
|
return generated;
|
||||||
|
} else {
|
||||||
|
return _virtualSymbols.get(symbol)!;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return `${VirtualId.PREFIX}${_virtualIdCounter++}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VirtualId.PREFIX = '_vid';
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {ActionSummary} from 'app/common/ActionSummary';
|
import {ActionSummary} from 'app/common/ActionSummary';
|
||||||
import {ApplyUAResult, ForkResult, PermissionDataWithExtraUsers, QueryFilters} from 'app/common/ActiveDocAPI';
|
import {ApplyUAResult, ForkResult, FormulaTimingInfo,
|
||||||
|
PermissionDataWithExtraUsers, QueryFilters, TimingStatus} from 'app/common/ActiveDocAPI';
|
||||||
import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompts';
|
import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompts';
|
||||||
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';
|
||||||
@ -512,14 +513,18 @@ export interface DocAPI {
|
|||||||
getAssistance(params: AssistanceRequest): Promise<AssistanceResponse>;
|
getAssistance(params: AssistanceRequest): Promise<AssistanceResponse>;
|
||||||
/**
|
/**
|
||||||
* Check if the document is currently in timing mode.
|
* Check if the document is currently in timing mode.
|
||||||
|
* Status is either
|
||||||
|
* - 'active' if timings are enabled.
|
||||||
|
* - 'pending' if timings are enabled but we can't get the data yet (as engine is blocked)
|
||||||
|
* - 'disabled' if timings are disabled.
|
||||||
*/
|
*/
|
||||||
timing(): Promise<{status: boolean}>;
|
timing(): Promise<TimingStatus>;
|
||||||
/**
|
/**
|
||||||
* Starts recording timing information for the document. Throws exception if timing is already
|
* Starts recording timing information for the document. Throws exception if timing is already
|
||||||
* in progress or you don't have permission to start timing.
|
* in progress or you don't have permission to start timing.
|
||||||
*/
|
*/
|
||||||
startTiming(): Promise<void>;
|
startTiming(): Promise<void>;
|
||||||
stopTiming(): Promise<void>;
|
stopTiming(): Promise<FormulaTimingInfo[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Operations that are supported by a doc worker.
|
// Operations that are supported by a doc worker.
|
||||||
@ -1134,7 +1139,7 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async timing(): Promise<{status: boolean}> {
|
public async timing(): Promise<TimingStatus> {
|
||||||
return this.requestJson(`${this._url}/timing`);
|
return this.requestJson(`${this._url}/timing`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1142,8 +1147,8 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
|
|||||||
await this.request(`${this._url}/timing/start`, {method: 'POST'});
|
await this.request(`${this._url}/timing/start`, {method: 'POST'});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stopTiming(): Promise<void> {
|
public async stopTiming(): Promise<FormulaTimingInfo[]> {
|
||||||
await this.request(`${this._url}/timing/stop`, {method: 'POST'});
|
return await this.requestJson(`${this._url}/timing/stop`, {method: 'POST'});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getRecords(tableId: string, endpoint: 'data' | 'records', options?: GetRowsParams): Promise<any> {
|
private _getRecords(tableId: string, endpoint: 'data' | 'records', options?: GetRowsParams): Promise<any> {
|
||||||
|
@ -14,7 +14,7 @@ import clone = require('lodash/clone');
|
|||||||
import pickBy = require('lodash/pickBy');
|
import pickBy = require('lodash/pickBy');
|
||||||
import slugify from 'slugify';
|
import slugify from 'slugify';
|
||||||
|
|
||||||
export const SpecialDocPage = StringUnion('code', 'acl', 'data', 'GristDocTour', 'settings', 'webhook');
|
export const SpecialDocPage = StringUnion('code', 'acl', 'data', 'GristDocTour', 'settings', 'webhook', 'timing');
|
||||||
type SpecialDocPage = typeof SpecialDocPage.type;
|
type SpecialDocPage = typeof SpecialDocPage.type;
|
||||||
export type IDocPage = number | SpecialDocPage;
|
export type IDocPage = number | SpecialDocPage;
|
||||||
|
|
||||||
|
@ -1841,6 +1841,14 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async sendTimingsNotification() {
|
||||||
|
await this.docClients.broadcastDocMessage(null, 'docChatter', {
|
||||||
|
timing: {
|
||||||
|
status: this.isTimingOn ? 'active' : 'disabled'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public logTelemetryEvent(
|
public logTelemetryEvent(
|
||||||
docSession: OptDocSession | null,
|
docSession: OptDocSession | null,
|
||||||
event: TelemetryEvent,
|
event: TelemetryEvent,
|
||||||
@ -1883,6 +1891,8 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async startTiming(): Promise<void> {
|
public async startTiming(): Promise<void> {
|
||||||
|
await this.waitForInitialization();
|
||||||
|
|
||||||
// Set the flag to indicate that timing is on.
|
// Set the flag to indicate that timing is on.
|
||||||
this.isTimingOn = true;
|
this.isTimingOn = true;
|
||||||
|
|
||||||
@ -1896,9 +1906,12 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
|
|
||||||
// Mark self as in timing mode, in case we get reloaded.
|
// Mark self as in timing mode, in case we get reloaded.
|
||||||
this._docManager.restoreTimingOn(this.docName, true);
|
this._docManager.restoreTimingOn(this.docName, true);
|
||||||
|
await this.sendTimingsNotification();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stopTiming(): Promise<FormulaTimingInfo[]> {
|
public async stopTiming(): Promise<FormulaTimingInfo[]> {
|
||||||
|
await this.waitForInitialization();
|
||||||
|
|
||||||
// First call the data engine to stop timing, and gather results.
|
// First call the data engine to stop timing, and gather results.
|
||||||
const timingResults = await this._pyCall('stop_timing');
|
const timingResults = await this._pyCall('stop_timing');
|
||||||
|
|
||||||
@ -1906,10 +1919,14 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
this.isTimingOn = false;
|
this.isTimingOn = false;
|
||||||
this._docManager.restoreTimingOn(this.docName, false);
|
this._docManager.restoreTimingOn(this.docName, false);
|
||||||
|
|
||||||
|
await this.sendTimingsNotification();
|
||||||
|
|
||||||
return timingResults;
|
return timingResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getTimings(): Promise<FormulaTimingInfo[]|void> {
|
public async getTimings(): Promise<FormulaTimingInfo[]|void> {
|
||||||
|
await this.waitForInitialization();
|
||||||
|
|
||||||
if (this._modificationLock.isLocked()) {
|
if (this._modificationLock.isLocked()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,7 @@ export const DEFAULT_CACHE_TTL = 10000;
|
|||||||
export const RECOVERY_CACHE_TTL = 30000; // 30 seconds
|
export const RECOVERY_CACHE_TTL = 30000; // 30 seconds
|
||||||
|
|
||||||
// How long to remember the timing mode of a document.
|
// How long to remember the timing mode of a document.
|
||||||
export const TIMING_ON_CACHE_TTL = 30000; // 30 seconds
|
export const TIMING_ON_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DocManager keeps track of "active" Grist documents, i.e. those loaded
|
* DocManager keeps track of "active" Grist documents, i.e. those loaded
|
||||||
@ -416,6 +416,7 @@ export class DocManager extends EventEmitter {
|
|||||||
recoveryMode: activeDoc.recoveryMode,
|
recoveryMode: activeDoc.recoveryMode,
|
||||||
userOverride,
|
userOverride,
|
||||||
docUsage,
|
docUsage,
|
||||||
|
isTimingOn: activeDoc.isTimingOn,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!activeDoc.muted) {
|
if (!activeDoc.muted) {
|
||||||
|
@ -345,6 +345,10 @@ export async function doExportSection(
|
|||||||
sortSpec = sortSpec || gutil.safeJsonParse(viewSection.sortColRefs, []);
|
sortSpec = sortSpec || gutil.safeJsonParse(viewSection.sortColRefs, []);
|
||||||
sortSpec = sortSpec!.map((colSpec) => {
|
sortSpec = sortSpec!.map((colSpec) => {
|
||||||
const colRef = Sort.getColRef(colSpec);
|
const colRef = Sort.getColRef(colSpec);
|
||||||
|
if (typeof colRef !== 'number') {
|
||||||
|
// colRef might be string for virtual tables, but we don't support them here.
|
||||||
|
throw new Error(`Unsupported colRef type: ${typeof colRef}`);
|
||||||
|
}
|
||||||
const col = metaColumns.getRecord(colRef);
|
const col = metaColumns.getRecord(colRef);
|
||||||
if (!col) {
|
if (!col) {
|
||||||
return 0;
|
return 0;
|
||||||
|
@ -22,6 +22,10 @@ export class ServerColumnGetters implements ColumnGetters, ColumnGettersByColId
|
|||||||
|
|
||||||
public getColGetter(colSpec: Sort.ColSpec): ColumnGetter | null {
|
public getColGetter(colSpec: Sort.ColSpec): ColumnGetter | null {
|
||||||
const colRef = Sort.getColRef(colSpec);
|
const colRef = Sort.getColRef(colSpec);
|
||||||
|
if (typeof colRef !== 'number') {
|
||||||
|
// colRef might be string for virtual tables, but we don't support them here.
|
||||||
|
throw new Error(`Unsupported colRef type: ${typeof colRef}`);
|
||||||
|
}
|
||||||
const colId = this._colIndices.get(colRef);
|
const colId = this._colIndices.get(colRef);
|
||||||
if (colId === undefined) {
|
if (colId === undefined) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -172,7 +172,7 @@ def run(sandbox):
|
|||||||
|
|
||||||
@export
|
@export
|
||||||
def get_timings():
|
def get_timings():
|
||||||
return eng._timing.get()
|
return eng._timing.get(False)
|
||||||
|
|
||||||
export(parse_predicate_formula)
|
export(parse_predicate_formula)
|
||||||
export(eng.load_empty)
|
export(eng.load_empty)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Sort } from 'app/common/SortSpec';
|
import { Sort, VirtualId } from 'app/common/SortSpec';
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
|
|
||||||
const { flipSort: flipColDirection, parseSortColRefs, reorderSortRefs } = Sort;
|
const { flipSort: flipColDirection, parseSortColRefs, reorderSortRefs } = Sort;
|
||||||
@ -76,11 +76,22 @@ describe('sortUtil', function () {
|
|||||||
assert.deepEqual(Sort.setColDirection('2:emptyLast', Sort.DESC), '-2:emptyLast');
|
assert.deepEqual(Sort.setColDirection('2:emptyLast', Sort.DESC), '-2:emptyLast');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should create column expressions for virtual ids', function () {
|
||||||
|
assert.deepEqual(Sort.setColDirection(VirtualId('test'), Sort.DESC), `-${VirtualId('test')}`);
|
||||||
|
assert.deepEqual(Sort.setColDirection(VirtualId('test'), Sort.ASC), VirtualId('test'));
|
||||||
|
assert.deepEqual(Sort.setColDirection(`-${VirtualId('test')}`, Sort.ASC), VirtualId('test'));
|
||||||
|
assert.deepEqual(Sort.setColDirection(`-${VirtualId('test')}`, Sort.DESC), `-${VirtualId('test')}`);
|
||||||
|
});
|
||||||
|
|
||||||
const empty = { emptyLast: false, orderByChoice: false, naturalSort: false };
|
const empty = { emptyLast: false, orderByChoice: false, naturalSort: false };
|
||||||
|
|
||||||
it('should parse details', function () {
|
it('should parse details', function () {
|
||||||
assert.deepEqual(Sort.specToDetails(2), { colRef: 2, direction: Sort.ASC });
|
assert.deepEqual(Sort.specToDetails(2), { colRef: 2, direction: Sort.ASC });
|
||||||
assert.deepEqual(Sort.specToDetails(-2), { colRef: 2, direction: Sort.DESC });
|
assert.deepEqual(Sort.specToDetails(-2), { colRef: 2, direction: Sort.DESC });
|
||||||
|
|
||||||
|
assert.deepEqual(Sort.specToDetails(VirtualId('test')), { colRef: VirtualId('test'), direction: Sort.ASC });
|
||||||
|
assert.deepEqual(Sort.specToDetails(`-${VirtualId('test')}`), { colRef: VirtualId('test'), direction: Sort.DESC });
|
||||||
|
|
||||||
assert.deepEqual(Sort.specToDetails('-2:emptyLast'),
|
assert.deepEqual(Sort.specToDetails('-2:emptyLast'),
|
||||||
{ ...empty, colRef: 2, direction: Sort.DESC, emptyLast: true });
|
{ ...empty, colRef: 2, direction: Sort.DESC, emptyLast: true });
|
||||||
assert.deepEqual(Sort.specToDetails('-2:emptyLast;orderByChoice'), {
|
assert.deepEqual(Sort.specToDetails('-2:emptyLast;orderByChoice'), {
|
||||||
@ -93,6 +104,10 @@ describe('sortUtil', function () {
|
|||||||
|
|
||||||
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.ASC }), 2);
|
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.ASC }), 2);
|
||||||
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.DESC }), -2);
|
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.DESC }), -2);
|
||||||
|
|
||||||
|
assert.deepEqual(Sort.detailsToSpec({ colRef: VirtualId('test'), direction: Sort.ASC }), VirtualId('test'));
|
||||||
|
assert.deepEqual(Sort.detailsToSpec({ colRef: VirtualId('test'), direction: Sort.DESC }), `-${VirtualId('test')}`);
|
||||||
|
|
||||||
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.ASC, emptyLast: true }), '2:emptyLast');
|
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.ASC, emptyLast: true }), '2:emptyLast');
|
||||||
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.DESC, emptyLast: true }), '-2:emptyLast');
|
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.DESC, emptyLast: true }), '-2:emptyLast');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
|
@ -158,7 +158,7 @@ describe('CopyPaste', function() {
|
|||||||
|
|
||||||
// Change the document locale
|
// Change the document locale
|
||||||
await gu.openDocumentSettings();
|
await gu.openDocumentSettings();
|
||||||
await driver.findWait('.test-locale-autocomplete', 500).click();
|
await driver.findWait('.test-settings-locale-autocomplete', 500).click();
|
||||||
await driver.sendKeys("Germany", Key.ENTER);
|
await driver.sendKeys("Germany", Key.ENTER);
|
||||||
await gu.waitForServer();
|
await gu.waitForServer();
|
||||||
await driver.navigate().back();
|
await driver.navigate().back();
|
||||||
|
@ -77,10 +77,10 @@ describe('NumericEditor', function() {
|
|||||||
|
|
||||||
// Set locale for the document.
|
// Set locale for the document.
|
||||||
await gu.openDocumentSettings();
|
await gu.openDocumentSettings();
|
||||||
await driver.findWait('.test-locale-autocomplete', 500).click();
|
await driver.findWait('.test-settings-locale-autocomplete', 500).click();
|
||||||
await driver.sendKeys(options.locale, Key.ENTER);
|
await driver.sendKeys(options.locale, Key.ENTER);
|
||||||
await gu.waitForServer();
|
await gu.waitForServer();
|
||||||
assert.equal(await driver.find('.test-locale-autocomplete input').value(), options.locale);
|
assert.equal(await driver.find('.test-settings-locale-autocomplete input').value(), options.locale);
|
||||||
await gu.openPage('Table1');
|
await gu.openPage('Table1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
196
test/nbrowser/Timing.ts
Normal file
196
test/nbrowser/Timing.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import { DocAPI, UserAPI } from "app/common/UserAPI";
|
||||||
|
import difference from 'lodash/difference';
|
||||||
|
import { assert, driver } from "mocha-webdriver";
|
||||||
|
import * as gu from "test/nbrowser/gristUtils";
|
||||||
|
import { setupTestSuite } from "test/nbrowser/testUtils";
|
||||||
|
|
||||||
|
describe("Timing", function () {
|
||||||
|
this.timeout(20000);
|
||||||
|
const cleanup = setupTestSuite();
|
||||||
|
|
||||||
|
let docApi: DocAPI;
|
||||||
|
let userApi: UserAPI;
|
||||||
|
let docId: string;
|
||||||
|
let session: gu.Session;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
session = await gu.session().teamSite.login();
|
||||||
|
docId = await session.tempNewDoc(cleanup);
|
||||||
|
userApi = session.createHomeApi();
|
||||||
|
docApi = userApi.getDocAPI(docId);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function assertOn() {
|
||||||
|
await gu.waitToPass(async () => {
|
||||||
|
assert.equal(await timingText.text(), "Timing is on...");
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.isTrue(await stopTiming.visible());
|
||||||
|
assert.isFalse(await startTiming.present());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertOff() {
|
||||||
|
await gu.waitToPass(async () => {
|
||||||
|
assert.equal(await timingText.text(), "Find slow formulas");
|
||||||
|
});
|
||||||
|
assert.isTrue(await startTiming.visible());
|
||||||
|
assert.isFalse(await stopTiming.present());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
it("should allow to start session", async function () {
|
||||||
|
await gu.openDocumentSettings();
|
||||||
|
// Make sure we see the timing button.
|
||||||
|
await assertOff();
|
||||||
|
|
||||||
|
// Start timing.
|
||||||
|
await startTiming.click();
|
||||||
|
|
||||||
|
// Wait for modal.
|
||||||
|
await modal.wait();
|
||||||
|
|
||||||
|
// We have two options.
|
||||||
|
assert.isTrue(await optionStart.visible());
|
||||||
|
assert.isTrue(await optionReload.visible());
|
||||||
|
|
||||||
|
// Start is selected by default.
|
||||||
|
assert.isTrue(await optionStart.checked());
|
||||||
|
assert.isFalse(await optionReload.checked());
|
||||||
|
|
||||||
|
await modalConfirm.click();
|
||||||
|
|
||||||
|
await assertOn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reflect that in the API', async function() {
|
||||||
|
assert.equal(await docApi.timing().then(x => x.status), 'active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop session from outside', async function() {
|
||||||
|
await docApi.stopTiming();
|
||||||
|
await assertOff();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start session from API', async function() {
|
||||||
|
await docApi.startTiming();
|
||||||
|
|
||||||
|
// Add new record through the API (to trigger formula calculations).
|
||||||
|
await userApi.applyUserActions(docId, [
|
||||||
|
['AddRecord', 'Table1', null, {}]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show result and stop session', async function() {
|
||||||
|
// The stop button is actually stop and show results, and it will open new window in.
|
||||||
|
const myTab = await gu.myTab();
|
||||||
|
const tabs = await driver.getAllWindowHandles();
|
||||||
|
await stopTiming.click();
|
||||||
|
|
||||||
|
// Now new tab will be opened, and the timings will be stopped.
|
||||||
|
await gu.waitToPass(async () => {
|
||||||
|
assert.equal(await docApi.timing().then(x => x.status), 'disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the new tab.
|
||||||
|
const newTab = difference(await driver.getAllWindowHandles(), tabs)[0];
|
||||||
|
assert.isDefined(newTab);
|
||||||
|
await driver.switchTo().window(newTab);
|
||||||
|
|
||||||
|
// Sanity check that we see some results.
|
||||||
|
assert.isTrue(await driver.findContentWait('div', 'Formula timer', 1000).isDisplayed());
|
||||||
|
|
||||||
|
await gu.waitToPass(async () => {
|
||||||
|
assert.equal(await gu.getCell(0, 1).getText(), 'Table1');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch back to the original tab.
|
||||||
|
await myTab.open();
|
||||||
|
|
||||||
|
// Make sure controls are back to the initial state.
|
||||||
|
await assertOff();
|
||||||
|
|
||||||
|
// Close the new tab.
|
||||||
|
await driver.switchTo().window(newTab);
|
||||||
|
await driver.close();
|
||||||
|
await myTab.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow to time the document load", async function () {
|
||||||
|
await assertOff();
|
||||||
|
|
||||||
|
await startTiming.click();
|
||||||
|
await modal.wait();
|
||||||
|
|
||||||
|
// Check that cancel works.
|
||||||
|
await modalCancel.click();
|
||||||
|
assert.isFalse(await modal.present());
|
||||||
|
await assertOff();
|
||||||
|
|
||||||
|
// Open modal once again but this time select reload.
|
||||||
|
await startTiming.click();
|
||||||
|
await optionReload.click();
|
||||||
|
assert.isTrue(await optionReload.checked());
|
||||||
|
await modalConfirm.click();
|
||||||
|
|
||||||
|
// We will see spinner.
|
||||||
|
await gu.waitToPass(async () => {
|
||||||
|
await driver.findContentWait('div', 'Loading timing data.', 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// We land on the timing page in the same tab.
|
||||||
|
await gu.waitToPass(async () => {
|
||||||
|
assert.isTrue(await driver.findContentWait('div', 'Formula timer', 1000).isDisplayed());
|
||||||
|
assert.equal(await gu.getCell(0, 1).getText(), 'Table1');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refreshing this tab will move us to the settings page.
|
||||||
|
await driver.navigate().refresh();
|
||||||
|
await gu.waitForUrl('/settings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const element = (testId: string) => ({
|
||||||
|
element() {
|
||||||
|
return driver.find(testId);
|
||||||
|
},
|
||||||
|
async wait() {
|
||||||
|
await driver.findWait(testId, 1000);
|
||||||
|
},
|
||||||
|
async visible() {
|
||||||
|
return await this.element().isDisplayed();
|
||||||
|
},
|
||||||
|
async present() {
|
||||||
|
return await this.element().isPresent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = (testId: string) => ({
|
||||||
|
...element(testId),
|
||||||
|
async text() {
|
||||||
|
return this.element().getText();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = (testId: string) => ({
|
||||||
|
...element(testId),
|
||||||
|
async click() {
|
||||||
|
await gu.scrollIntoView(this.element());
|
||||||
|
await this.element().click();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const option = (testId: string) => ({
|
||||||
|
...button(testId),
|
||||||
|
async checked() {
|
||||||
|
return 'true' === await this.element().findClosest("label").find("input[type='checkbox']").getAttribute('checked');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const startTiming = button(".test-settings-timing-start");
|
||||||
|
const stopTiming = button(".test-settings-timing-stop");
|
||||||
|
const timingText = label(".test-settings-timing-desc");
|
||||||
|
const modal = element(".test-settings-timing-modal");
|
||||||
|
const optionStart = option('.test-settings-timing-modal-option-adhoc');
|
||||||
|
const optionReload = option('.test-settings-timing-modal-option-reload');
|
||||||
|
const modalConfirm = button('.test-settings-timing-modal-confirm');
|
||||||
|
const modalCancel = button('.test-settings-timing-modal-cancel');
|
@ -4261,7 +4261,7 @@ function testDocApi() {
|
|||||||
await notFoundCalled.waitAndReset();
|
await notFoundCalled.waitAndReset();
|
||||||
|
|
||||||
// But the working endpoint won't be called more then once.
|
// But the working endpoint won't be called more then once.
|
||||||
assert.isFalse(successCalled.called());
|
successCalled.assertNotCalled();
|
||||||
|
|
||||||
// Trigger second event.
|
// Trigger second event.
|
||||||
await doc.addRows("Table1", {
|
await doc.addRows("Table1", {
|
||||||
@ -4273,13 +4273,13 @@ function testDocApi() {
|
|||||||
assert.deepEqual(firstRow, 1);
|
assert.deepEqual(firstRow, 1);
|
||||||
|
|
||||||
// But the working endpoint won't be called till we reset the queue.
|
// But the working endpoint won't be called till we reset the queue.
|
||||||
assert.isFalse(successCalled.called());
|
successCalled.assertNotCalled();
|
||||||
|
|
||||||
// Now reset the queue.
|
// Now reset the queue.
|
||||||
await clearQueue(docId);
|
await clearQueue(docId);
|
||||||
|
|
||||||
assert.isFalse(successCalled.called());
|
successCalled.assertNotCalled();
|
||||||
assert.isFalse(notFoundCalled.called());
|
notFoundCalled.assertNotCalled();
|
||||||
|
|
||||||
// Prepare for new calls.
|
// Prepare for new calls.
|
||||||
successCalled.reset();
|
successCalled.reset();
|
||||||
@ -4297,7 +4297,7 @@ function testDocApi() {
|
|||||||
// And the situation will be the same, the working endpoint won't be called till we reset the queue, but
|
// And the situation will be the same, the working endpoint won't be called till we reset the queue, but
|
||||||
// the error endpoint will be called with the third row multiple times.
|
// the error endpoint will be called with the third row multiple times.
|
||||||
await notFoundCalled.waitAndReset();
|
await notFoundCalled.waitAndReset();
|
||||||
assert.isFalse(successCalled.called());
|
successCalled.assertNotCalled();
|
||||||
|
|
||||||
// Cleanup everything, we will now test request timeouts.
|
// Cleanup everything, we will now test request timeouts.
|
||||||
await Promise.all(cleanup.map(fn => fn())).finally(() => cleanup.length = 0);
|
await Promise.all(cleanup.map(fn => fn())).finally(() => cleanup.length = 0);
|
||||||
@ -4319,7 +4319,7 @@ function testDocApi() {
|
|||||||
// Long will be started immediately.
|
// Long will be started immediately.
|
||||||
await longStarted.waitAndReset();
|
await longStarted.waitAndReset();
|
||||||
// But it won't be finished.
|
// But it won't be finished.
|
||||||
assert.isFalse(longFinished.called());
|
longFinished.assertNotCalled();
|
||||||
// It will be aborted.
|
// It will be aborted.
|
||||||
controller.abort();
|
controller.abort();
|
||||||
assert.deepEqual(await longFinished.waitAndReset(), [408, 4]);
|
assert.deepEqual(await longFinished.waitAndReset(), [408, 4]);
|
||||||
@ -4333,7 +4333,7 @@ function testDocApi() {
|
|||||||
// abort it till the end of this test.
|
// abort it till the end of this test.
|
||||||
assert.deepEqual(await successCalled.waitAndReset(), 5);
|
assert.deepEqual(await successCalled.waitAndReset(), 5);
|
||||||
assert.deepEqual(await longStarted.waitAndReset(), 5);
|
assert.deepEqual(await longStarted.waitAndReset(), 5);
|
||||||
assert.isFalse(longFinished.called());
|
longFinished.assertNotCalled();
|
||||||
|
|
||||||
// Remember this controller for cleanup.
|
// Remember this controller for cleanup.
|
||||||
const controller5 = controller;
|
const controller5 = controller;
|
||||||
@ -4343,8 +4343,8 @@ function testDocApi() {
|
|||||||
B: [true],
|
B: [true],
|
||||||
});
|
});
|
||||||
// We are now completely stuck on the 5th row webhook.
|
// We are now completely stuck on the 5th row webhook.
|
||||||
assert.isFalse(successCalled.called());
|
successCalled.assertNotCalled();
|
||||||
assert.isFalse(longFinished.called());
|
longFinished.assertNotCalled();
|
||||||
// Clear the queue, it will free webhooks requests, but it won't cancel long handler on the external server
|
// Clear the queue, it will free webhooks requests, but it won't cancel long handler on the external server
|
||||||
// so it is still waiting.
|
// so it is still waiting.
|
||||||
assert.isTrue((await axios.delete(
|
assert.isTrue((await axios.delete(
|
||||||
@ -4356,8 +4356,8 @@ function testDocApi() {
|
|||||||
assert.deepEqual(await longFinished.waitAndReset(), [408, 5]);
|
assert.deepEqual(await longFinished.waitAndReset(), [408, 5]);
|
||||||
|
|
||||||
// We won't be called for the 6th row at all, as it was stuck and the queue was purged.
|
// We won't be called for the 6th row at all, as it was stuck and the queue was purged.
|
||||||
assert.isFalse(successCalled.called());
|
successCalled.assertNotCalled();
|
||||||
assert.isFalse(longStarted.called());
|
longStarted.assertNotCalled();
|
||||||
|
|
||||||
// Trigger next event.
|
// Trigger next event.
|
||||||
await doc.addRows("Table1", {
|
await doc.addRows("Table1", {
|
||||||
@ -4368,7 +4368,7 @@ function testDocApi() {
|
|||||||
assert.deepEqual(await successCalled.waitAndReset(), 7);
|
assert.deepEqual(await successCalled.waitAndReset(), 7);
|
||||||
assert.deepEqual(await longStarted.waitAndReset(), 7);
|
assert.deepEqual(await longStarted.waitAndReset(), 7);
|
||||||
// But we are stuck again.
|
// But we are stuck again.
|
||||||
assert.isFalse(longFinished.called());
|
longFinished.assertNotCalled();
|
||||||
// And we can abort current request from 7th row (6th row was skipped).
|
// And we can abort current request from 7th row (6th row was skipped).
|
||||||
controller.abort();
|
controller.abort();
|
||||||
assert.deepEqual(await longFinished.waitAndReset(), [408, 7]);
|
assert.deepEqual(await longFinished.waitAndReset(), [408, 7]);
|
||||||
@ -4411,7 +4411,7 @@ function testDocApi() {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
await longFinished.waitAndReset();
|
await longFinished.waitAndReset();
|
||||||
// The second one is not called.
|
// The second one is not called.
|
||||||
assert.isFalse(successCalled.called());
|
successCalled.assertNotCalled();
|
||||||
// Triggering next event, we will get only calls to the probe (first webhook).
|
// Triggering next event, we will get only calls to the probe (first webhook).
|
||||||
await doc.addRows("Table1", {
|
await doc.addRows("Table1", {
|
||||||
A: [2],
|
A: [2],
|
||||||
@ -4438,14 +4438,12 @@ function testDocApi() {
|
|||||||
await axios.post(`${serverUrl}/api/docs/${docId}/apply`, [
|
await axios.post(`${serverUrl}/api/docs/${docId}/apply`, [
|
||||||
['UpdateRecord', 'Table1', newRowIds[0], newValues],
|
['UpdateRecord', 'Table1', newRowIds[0], newValues],
|
||||||
], chimpy);
|
], chimpy);
|
||||||
await delay(100);
|
|
||||||
};
|
};
|
||||||
const assertSuccessNotCalled = async () => {
|
const assertSuccessNotCalled = async () => {
|
||||||
assert.isFalse(successCalled.called());
|
successCalled.assertNotCalled();
|
||||||
successCalled.reset();
|
successCalled.reset();
|
||||||
};
|
};
|
||||||
const assertSuccessCalled = async () => {
|
const assertSuccessCalled = async () => {
|
||||||
assert.isTrue(successCalled.called());
|
|
||||||
await successCalled.waitAndReset();
|
await successCalled.waitAndReset();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -4460,8 +4458,6 @@ function testDocApi() {
|
|||||||
B: [true],
|
B: [true],
|
||||||
C: ['c1']
|
C: ['c1']
|
||||||
});
|
});
|
||||||
await delay(100);
|
|
||||||
assert.isTrue(successCalled.called());
|
|
||||||
await successCalled.waitAndReset();
|
await successCalled.waitAndReset();
|
||||||
await modifyColumn({ C: 'c2' });
|
await modifyColumn({ C: 'c2' });
|
||||||
await assertSuccessNotCalled();
|
await assertSuccessNotCalled();
|
||||||
|
@ -232,7 +232,8 @@ describe('DocApi2', function() {
|
|||||||
// And check that we are still on.
|
// And check that we are still on.
|
||||||
resp = await axios.get(`${homeUrl}/api/docs/${docId}/timing`, chimpy);
|
resp = await axios.get(`${homeUrl}/api/docs/${docId}/timing`, chimpy);
|
||||||
assert.equal(resp.status, 200, JSON.stringify(resp.data));
|
assert.equal(resp.status, 200, JSON.stringify(resp.data));
|
||||||
assert.deepEqual(resp.data, {status: 'active', timing: []});
|
assert.equal(resp.data.status, 'active');
|
||||||
|
assert.isNotEmpty(resp.data.timing);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -306,7 +306,7 @@ describe('Webhooks-Proxy', function () {
|
|||||||
await notFoundCalled.waitAndReset();
|
await notFoundCalled.waitAndReset();
|
||||||
|
|
||||||
// But the working endpoint won't be called more then once.
|
// But the working endpoint won't be called more then once.
|
||||||
assert.isFalse(successCalled.called());
|
successCalled.assertNotCalled();
|
||||||
|
|
||||||
//Cleanup all
|
//Cleanup all
|
||||||
await Promise.all(cleanup.map(fn => fn())).finally(() => cleanup.length = 0);
|
await Promise.all(cleanup.map(fn => fn())).finally(() => cleanup.length = 0);
|
||||||
|
@ -1,44 +1,58 @@
|
|||||||
import {delay} from "bluebird";
|
import {delay} from 'bluebird';
|
||||||
|
import {assert} from 'chai';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper that creates a promise that can be resolved from outside.
|
* Helper that creates a promise that can be resolved from outside.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const methodCalled = signal();
|
||||||
|
* setTimeout(() => methodCalled.emit(), 1000);
|
||||||
|
* methodCalled.assertNotCalled(); // won't throw as the method hasn't been called yet
|
||||||
|
* await methodCalled.wait(); // will wait for the method to be called
|
||||||
|
* await methodCalled.wait(); // can be called multiple times
|
||||||
|
* methodCalled.reset(); // resets the signal (so that it can be awaited again)
|
||||||
|
* setTimeout(() => methodCalled.emit(), 3000);
|
||||||
|
* await methodCalled.wait(); // will fail, as we wait only 2 seconds
|
||||||
*/
|
*/
|
||||||
export function signal() {
|
export function signal() {
|
||||||
let resolve: null | ((data: any) => void) = null;
|
let resolve: null | ((data: any) => void) = null;
|
||||||
let promise: null | Promise<any> = null;
|
let promise: null | Promise<any> = null;
|
||||||
let called = false;
|
let called = false;
|
||||||
return {
|
return {
|
||||||
emit(data: any) {
|
emit(data: any) {
|
||||||
if (!resolve) {
|
if (!resolve) {
|
||||||
throw new Error("signal.emit() called before signal.reset()");
|
throw new Error("signal.emit() called before signal.reset()");
|
||||||
}
|
}
|
||||||
called = true;
|
called = true;
|
||||||
resolve(data);
|
resolve(data);
|
||||||
},
|
},
|
||||||
async wait() {
|
async wait() {
|
||||||
if (!promise) {
|
if (!promise) {
|
||||||
throw new Error("signal.wait() called before signal.reset()");
|
throw new Error("signal.wait() called before signal.reset()");
|
||||||
}
|
}
|
||||||
const proms = Promise.race([promise, delay(2000).then(() => {
|
const proms = Promise.race([
|
||||||
throw new Error("signal.wait() timed out");
|
promise,
|
||||||
})]);
|
delay(2000).then(() => {
|
||||||
return await proms;
|
throw new Error("signal.wait() timed out");
|
||||||
},
|
}),
|
||||||
async waitAndReset() {
|
]);
|
||||||
try {
|
return await proms;
|
||||||
return await this.wait();
|
},
|
||||||
} finally {
|
async waitAndReset() {
|
||||||
this.reset();
|
try {
|
||||||
}
|
return await this.wait();
|
||||||
},
|
} finally {
|
||||||
called() {
|
this.reset();
|
||||||
return called;
|
}
|
||||||
},
|
},
|
||||||
reset() {
|
assertNotCalled() {
|
||||||
called = false;
|
assert.isFalse(called);
|
||||||
promise = new Promise((res) => {
|
},
|
||||||
resolve = res;
|
reset() {
|
||||||
});
|
called = false;
|
||||||
}
|
promise = new Promise((res) => {
|
||||||
};
|
resolve = res;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user