(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:
Jarosław Sadziński 2024-05-21 18:27:06 +02:00
parent 60423edc17
commit a6ffa6096a
29 changed files with 858 additions and 144 deletions

View File

@ -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;

View File

@ -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

View File

@ -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');
} }
} }

View File

@ -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; }

View File

@ -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();
}); });
}); });

View File

@ -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;

View File

@ -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
View 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;
`);

View File

@ -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',

View File

@ -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;

View File

@ -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.

View File

@ -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';
}
}; };
} }

View File

@ -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;

View File

@ -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';

View File

@ -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> {

View File

@ -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;

View File

@ -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;
} }

View File

@ -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) {

View File

@ -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;

View File

@ -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;

View File

@ -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)

View File

@ -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(

View File

@ -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();

View File

@ -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
View 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');

View File

@ -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();

View File

@ -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);
}); });
}); });
}); });

View File

@ -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);

View File

@ -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;
});
},
};
} }