diff --git a/app/client/aclui/AccessRules.ts b/app/client/aclui/AccessRules.ts index afe04df5..28086c83 100644 --- a/app/client/aclui/AccessRules.ts +++ b/app/client/aclui/AccessRules.ts @@ -8,6 +8,7 @@ import {aclSelect} from 'app/client/aclui/ACLSelect'; import {ACLUsersPopup} from 'app/client/aclui/ACLUsers'; import {PermissionKey, permissionsWidget} from 'app/client/aclui/PermissionsWidget'; import {GristDoc} from 'app/client/components/GristDoc'; +import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {reportError, UserError} from 'app/client/models/errors'; import {TableData} from 'app/client/models/TableData'; import {shadowScroll} from 'app/client/ui/shadowScroll'; @@ -308,6 +309,13 @@ export class AccessRules extends Disposable { }); } + logTelemetryEvent('changedAccessRules', { + full: { + docIdDigest: this.gristDoc.docId(), + ruleCount: newRules.length, + }, + }); + // We need to fill in rulePos values. We'll add them in the order the rules are listed (since // this.getRules() returns them in a suitable order), keeping rulePos unchanged when possible. let lastGoodRulePos = 0; diff --git a/app/client/components/BehavioralPromptsManager.ts b/app/client/components/BehavioralPromptsManager.ts index 11335ba2..5b365597 100644 --- a/app/client/components/BehavioralPromptsManager.ts +++ b/app/client/components/BehavioralPromptsManager.ts @@ -1,4 +1,5 @@ import {showBehavioralPrompt} from 'app/client/components/modals'; +import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {AppModel} from 'app/client/models/AppModel'; import {getUserPrefObs} from 'app/client/models/UserPrefs'; import {GristBehavioralPrompts} from 'app/client/ui/GristTooltips'; @@ -159,6 +160,8 @@ export class BehavioralPromptsManager extends Disposable { }); dom.onElem(refElement, 'click', () => close()); dom.onDisposeElem(refElement, () => close()); + + logTelemetryEvent('viewedTip', {full: {tipName: prompt}}); } private _showNextQueuedTip() { diff --git a/app/client/components/DocumentUsage.ts b/app/client/components/DocumentUsage.ts index 89b9a50b..1130e71a 100644 --- a/app/client/components/DocumentUsage.ts +++ b/app/client/components/DocumentUsage.ts @@ -2,7 +2,7 @@ import {cssBannerLink} from 'app/client/components/Banner'; import {DocPageModel} from 'app/client/models/DocPageModel'; import {urlState} from 'app/client/models/gristUrlState'; import {docListHeader} from 'app/client/ui/DocMenuCss'; -import {GristTooltips, TooltipContentFunc} from 'app/client/ui/GristTooltips'; +import {Tooltip} from 'app/client/ui/GristTooltips'; import {withInfoTooltip} from 'app/client/ui/tooltips'; import {mediaXSmall, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; @@ -81,7 +81,7 @@ export class DocumentUsage extends Disposable { maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE, unit: 'MB', shouldHideLimits: maxValue === undefined, - tooltipContentFunc: GristTooltips.dataSize, + tooltip: 'dataSize', formatValue: (val) => { // To display a nice, round number for `maximumValue`, we first convert // to KiBs (base-2), and then convert to MBs (base-10). Normally, we wouldn't @@ -271,7 +271,7 @@ interface MetricOptions { // If true, limits will always be hidden, even if `maximumValue` is a positive number. shouldHideLimits?: boolean; // Shows an icon next to the metric name that displays a tooltip on hover. - tooltipContentFunc?: TooltipContentFunc; + tooltip?: Tooltip; formatValue?(value: number): string; } @@ -281,14 +281,11 @@ interface MetricOptions { * close `currentValue` is to hitting `maximumValue`. */ function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) { - const {name, tooltipContentFunc} = options; + const {name, tooltip} = options; return cssUsageMetric( cssMetricName( - tooltipContentFunc - ? withInfoTooltip( - cssOverflowableText(name, testId('name')), - tooltipContentFunc() - ) + tooltip + ? withInfoTooltip(cssOverflowableText(name, testId('name')), tooltip) : cssOverflowableText(name, testId('name')), ), buildUsageProgressBar(options), diff --git a/app/client/components/Forms/FormView.ts b/app/client/components/Forms/FormView.ts index 17979b16..d763c473 100644 --- a/app/client/components/Forms/FormView.ts +++ b/app/client/components/Forms/FormView.ts @@ -11,6 +11,7 @@ import {Disposable} from 'app/client/lib/dispose'; import {AsyncComputed, makeTestId, stopEvent} from 'app/client/lib/domUtils'; import {makeT} from 'app/client/lib/localization'; import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; +import {logTelemetryEvent} from 'app/client/lib/telemetry'; import DataTableModel from 'app/client/models/DataTableModel'; import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; import {ShareRec} from 'app/client/models/entities/ShareRec'; @@ -514,6 +515,13 @@ export class FormView extends Disposable { throw ex; } } + + logTelemetryEvent('publishedForm', { + full: { + docIdDigest: this.gristDoc.docId(), + }, + }); + await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => { if (!validShare) { const shareRef = await this.gristDoc.docModel.docData.sendAction([ @@ -573,6 +581,12 @@ export class FormView extends Disposable { } private async _unpublishForm() { + logTelemetryEvent('unpublishedForm', { + full: { + docIdDigest: this.gristDoc.docId(), + }, + }); + await this.gristDoc.docModel.docData.bundleActions('Unpublish form', async () => { this.viewSection.shareOptionsObj.update({ publish: false, diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 8fff29a4..9bb943bf 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -47,9 +47,10 @@ import {DocTutorial} from 'app/client/ui/DocTutorial'; import {DocSettingsPage} from 'app/client/ui/DocumentSettings'; import {isTourActive} from "app/client/ui/OnBoardingPopups"; import {DefaultPageWidget, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; -import {linkFromId, selectBy} from 'app/client/ui/selectBy'; +import {linkFromId, NoLink, selectBy} from 'app/client/ui/selectBy'; import {WebhookPage} from 'app/client/ui/WebhookPage'; import {startWelcomeTour} from 'app/client/ui/WelcomeTour'; +import {getTelemetryWidgetTypeFromPageWidget} from 'app/client/ui/widgetTypesMap'; import {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer'; import {isNarrowScreen, mediaSmall, mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars'; import {IconName} from 'app/client/ui2018/IconList'; @@ -882,6 +883,13 @@ export class GristDoc extends DisposableWithEvents { return; } } + + const widgetType = getTelemetryWidgetTypeFromPageWidget(val); + logTelemetryEvent('addedWidget', {full: {docIdDigest: this.docId(), widgetType}}); + if (val.link !== NoLink) { + logTelemetryEvent('linkedWidget', {full: {docIdDigest: this.docId(), widgetType}}); + } + const res: {sectionRef: number} = await docData.bundleActions( t("Added new linked section to view {{viewName}}", {viewName}), () => this.addWidgetToPageImpl(val, tableId ?? null) @@ -932,6 +940,14 @@ export class GristDoc extends DisposableWithEvents { * Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`. */ public async addNewPage(val: IPageWidget) { + logTelemetryEvent('addedPage', {full: {docIdDigest: this.docId()}}); + logTelemetryEvent('addedWidget', { + full: { + docIdDigest: this.docId(), + widgetType: getTelemetryWidgetTypeFromPageWidget(val), + }, + }); + if (val.table === 'New Table') { const name = await this._promptForName(); if (name === undefined) { diff --git a/app/client/components/RawDataPage.ts b/app/client/components/RawDataPage.ts index 45a73969..a38c57d6 100644 --- a/app/client/components/RawDataPage.ts +++ b/app/client/components/RawDataPage.ts @@ -4,12 +4,14 @@ import {DocumentUsage} from 'app/client/components/DocumentUsage'; import {GristDoc} from 'app/client/components/GristDoc'; import {printViewSection} from 'app/client/components/Printing'; import {ViewSectionHelper} from 'app/client/components/ViewLayout'; +import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs'; import {reportError} from 'app/client/models/errors'; import {ViewSectionRec} from 'app/client/models/DocModel'; import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom'; +import {getTelemetryWidgetTypeFromVS} from 'app/client/ui/widgetTypesMap'; const testId = makeTestId('test-raw-data-'); @@ -82,6 +84,10 @@ export class RawDataPopup extends Disposable { if (this._viewSection.isRaw.peek()) { throw new Error("Can't delete a raw section"); } + + const widgetType = getTelemetryWidgetTypeFromVS(this._viewSection); + logTelemetryEvent('deletedWidget', {full: {docIdDigest: this._gristDoc.docId(), widgetType}}); + this._gristDoc.docData.sendAction(['RemoveViewSection', this._viewSection.id.peek()]).catch(reportError); }, }; diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts index 40d93ac5..12efaa90 100644 --- a/app/client/components/ViewLayout.ts +++ b/app/client/components/ViewLayout.ts @@ -14,8 +14,10 @@ import {LayoutTray} from 'app/client/components/LayoutTray'; import {printViewSection} from 'app/client/components/Printing'; import {Delay} from 'app/client/lib/Delay'; import {createObsArray} from 'app/client/lib/koArrayWrap'; +import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; import {reportError} from 'app/client/models/errors'; +import {getTelemetryWidgetTypeFromVS} from 'app/client/ui/widgetTypesMap'; import {isNarrowScreen, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; @@ -279,6 +281,14 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { // more than one viewsection in the view. public removeViewSection(viewSectionRowId: number) { this.maximized.set(null); + const viewSection = this.viewModel.viewSections().all().find(s => s.getRowId() === viewSectionRowId); + if (!viewSection) { + throw new Error(`Section not found: ${viewSectionRowId}`); + } + + const widgetType = getTelemetryWidgetTypeFromVS(viewSection); + logTelemetryEvent('deletedWidget', {full: {docIdDigest: this.gristDoc.docId(), widgetType}}); + this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError); } diff --git a/app/client/components/duplicatePage.ts b/app/client/components/duplicatePage.ts index db49c071..8bca51eb 100644 --- a/app/client/components/duplicatePage.ts +++ b/app/client/components/duplicatePage.ts @@ -1,4 +1,5 @@ import { GristDoc } from 'app/client/components/GristDoc'; +import { logTelemetryEvent } from 'app/client/lib/telemetry'; import { ViewFieldRec, ViewSectionRec } from 'app/client/models/DocModel'; import { cssInput } from 'app/client/ui/cssInput'; import { cssField, cssLabel } from 'app/client/ui/MakeCopyMenu'; @@ -43,6 +44,8 @@ async function makeDuplicate(gristDoc: GristDoc, pageId: number, pageName: strin await gristDoc.docData.bundleActions( t("Duplicate page {{pageName}}", {pageName}), async () => { + logTelemetryEvent('addedPage', {full: {docIdDigest: gristDoc.docId()}}); + // create new view and new sections const results = await createNewViewSections(gristDoc.docData, viewSections); viewRef = results[0].viewRef; diff --git a/app/client/ui/FieldConfig.ts b/app/client/ui/FieldConfig.ts index 62f1caec..606664fe 100644 --- a/app/client/ui/FieldConfig.ts +++ b/app/client/ui/FieldConfig.ts @@ -2,7 +2,6 @@ import {makeT} from 'app/client/lib/localization'; import {GristDoc} from 'app/client/components/GristDoc'; import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec'; import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight'; -import {GristTooltips} from 'app/client/ui/GristTooltips'; import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles'; import {withInfoTooltip} from 'app/client/ui/tooltips'; import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas'; @@ -359,7 +358,7 @@ export function buildFormulaConfig( dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)), testId("field-set-trigger") ), - GristTooltips.setTriggerFormula(), + 'setTriggerFormula', )), cssRow(textButton( t("Make into data column"), @@ -412,7 +411,7 @@ export function buildFormulaConfig( dom.prop("disabled", disableOtherActions), testId("field-set-trigger") ), - GristTooltips.setTriggerFormula() + 'setTriggerFormula' )), ]) ]) diff --git a/app/client/ui/GridViewMenus.ts b/app/client/ui/GridViewMenus.ts index 04c65f91..33298075 100644 --- a/app/client/ui/GridViewMenus.ts +++ b/app/client/ui/GridViewMenus.ts @@ -4,7 +4,6 @@ import GridView from 'app/client/components/GridView'; import {makeT} from 'app/client/lib/localization'; import {ColumnRec} from "app/client/models/entities/ColumnRec"; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; -import {GristTooltips} from 'app/client/ui/GristTooltips'; import {withInfoTooltip} from 'app/client/ui/tooltips'; import {isNarrowScreen, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {IconName} from "app/client/ui2018/IconList"; @@ -136,7 +135,7 @@ function buildAddNewColumMenuSection(gridView: GridView, index?: number): DomEle }, withInfoTooltip( t('Add formula column'), - GristTooltips.formulaColumn(), + 'formulaColumn', {variant: 'hover'} ), testId('new-columns-menu-add-formula'), @@ -385,7 +384,7 @@ function buildUUIDMenuItem(gridView: GridView, index?: number) { }, withInfoTooltip( t('UUID'), - GristTooltips.uuid(), + 'uuid', {variant: 'hover'} ), testId('new-columns-menu-shortcuts-uuid'), @@ -680,7 +679,7 @@ function buildLookupSection(gridView: GridView, index?: number){ menuSubHeader( withInfoTooltip( t('Lookups'), - GristTooltips.lookups(), + 'lookups', {variant: 'hover'} ), testId('new-columns-menu-lookups'), diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts index 48970d81..0737a2b3 100644 --- a/app/client/ui/GristTooltips.ts +++ b/app/client/ui/GristTooltips.ts @@ -38,7 +38,7 @@ export type Tooltip = | 'addColumnConditionalStyle' | 'uuid' | 'lookups' - | 'formulaColumn' + | 'formulaColumn'; export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents; diff --git a/app/client/ui/OnBoardingPopups.ts b/app/client/ui/OnBoardingPopups.ts index bf0285af..b00e1ac0 100644 --- a/app/client/ui/OnBoardingPopups.ts +++ b/app/client/ui/OnBoardingPopups.ts @@ -78,7 +78,7 @@ export interface IOnBoardingMsg { // starting a new one. const tourSingleton = Holder.create(null); -export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: () => void) { +export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: (lastMessageIndex: number) => void) { const ctl = OnBoardingPopupsCtl.create(tourSingleton, messages, onFinishCB); ctl.start().catch(reportError); } @@ -109,7 +109,7 @@ class OnBoardingPopupsCtl extends Disposable { private _overlay: HTMLElement; private _arrowEl = buildArrow(); - constructor(private _messages: IOnBoardingMsg[], private _onFinishCB: () => void) { + constructor(private _messages: IOnBoardingMsg[], private _onFinishCB: (lastMessageIndex: number) => void) { super(); if (this._messages.length === 0) { throw new OnBoardingError('messages should not be an empty list'); @@ -133,8 +133,8 @@ class OnBoardingPopupsCtl extends Disposable { }); } - private _finish() { - this._onFinishCB(); + private _finish(lastMessageIndex: number) { + this._onFinishCB(lastMessageIndex); this.dispose(); } @@ -143,9 +143,9 @@ class OnBoardingPopupsCtl extends Disposable { const entry = this._messages[newIndex]; if (!entry) { if (maybeClose) { + this._finish(ctlIndex); // User finished the tour, close and restart from the beginning if they reopen ctlIndex = 0; - this._finish(); } return; // gone out of bounds, probably by keyboard shortcut } @@ -266,7 +266,7 @@ class OnBoardingPopupsCtl extends Disposable { this._arrowEl, ContentWrapper( cssCloseButton(cssBigIcon('CrossBig'), - dom.on('click', () => this._finish()), + dom.on('click', () => this._finish(ctlIndex)), testId('close'), ), cssTitle(this._messages[ctlIndex].title), @@ -275,7 +275,7 @@ class OnBoardingPopupsCtl extends Disposable { testId('popup'), ), dom.onKeyDown({ - Escape: () => this._finish(), + Escape: () => this._finish(ctlIndex), ArrowLeft: () => this._move(-1), ArrowRight: () => this._move(+1), Enter: () => this._move(+1, true), diff --git a/app/client/ui/PageWidgetPicker.ts b/app/client/ui/PageWidgetPicker.ts index 6061ef35..954d5f6d 100644 --- a/app/client/ui/PageWidgetPicker.ts +++ b/app/client/ui/PageWidgetPicker.ts @@ -4,7 +4,6 @@ import {makeT} from 'app/client/lib/localization'; import {reportError} from 'app/client/models/AppModel'; import {ColumnRec, TableRec, ViewSectionRec} from 'app/client/models/DocModel'; import {PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features"; -import {GristTooltips} from 'app/client/ui/GristTooltips'; import {linkId, NoLink} from 'app/client/ui/selectBy'; import {overflowTooltip, withInfoTooltip} from 'app/client/ui/tooltips'; import {getWidgetTypes} from "app/client/ui/widgetTypesMap"; @@ -394,7 +393,7 @@ export class PageWidgetSelect extends Disposable { dom.update(cssSelect(this._value.link, this._selectByOptions!), testId('selectby')) ), - GristTooltips.selectBy(), + 'selectBy', {popupOptions: {attach: null}, domArgs: [ this._behavioralPromptsManager.attachTip('pageWidgetPickerSelectBy', { popupOptions: { diff --git a/app/client/ui/Pages.ts b/app/client/ui/Pages.ts index 47ccfd42..e6ea8a4b 100644 --- a/app/client/ui/Pages.ts +++ b/app/client/ui/Pages.ts @@ -2,6 +2,7 @@ import {createGroup} from 'app/client/components/commands'; import {duplicatePage} from 'app/client/components/duplicatePage'; import {GristDoc} from 'app/client/components/GristDoc'; import {makeT} from 'app/client/lib/localization'; +import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {PageRec} from 'app/client/models/DocModel'; import {urlState} from 'app/client/models/gristUrlState'; import MetaTableModel from 'app/client/models/MetaTableModel'; @@ -83,6 +84,8 @@ function buildDomFromTable(pagesTable: MetaTableModel, activeDoc: Grist } function removeView(activeDoc: GristDoc, viewId: number, pageName: string) { + logTelemetryEvent('deletedPage', {full: {docIdDigest: activeDoc.docId()}}); + const docData = activeDoc.docData; // Create a set with tables on other pages (but not on this one). const tablesOnOtherViews = new Set(activeDoc.docModel.viewSections.rowModels diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 4911d14e..961f28cd 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -25,6 +25,7 @@ import {domAsync} from 'app/client/lib/domAsync'; import * as imports from 'app/client/lib/imports'; import {makeT} from 'app/client/lib/localization'; import {createSessionObs, isBoolean, SessionObs} from 'app/client/lib/sessionObs'; +import {logTelemetryEvent} from 'app/client/lib/telemetry'; import {reportError} from 'app/client/models/AppModel'; import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig'; @@ -35,9 +36,9 @@ import {textarea} from 'app/client/ui/inputs'; import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {PredefinedCustomSectionConfig} from "app/client/ui/PredefinedCustomSectionConfig"; import {cssLabel} from 'app/client/ui/RightPanelStyles'; -import {linkId, selectBy} from 'app/client/ui/selectBy'; +import {linkId, NoLink, selectBy} from 'app/client/ui/selectBy'; import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig'; -import {widgetTypesMap} from "app/client/ui/widgetTypesMap"; +import {getTelemetryWidgetTypeFromVS, widgetTypesMap} from "app/client/ui/widgetTypesMap"; import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {buttonSelect} from 'app/client/ui2018/buttonSelect'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; @@ -792,7 +793,16 @@ export class RightPanel extends Disposable { ); }); - link.onWrite((val) => this._gristDoc.saveLink(val)); + link.onWrite(async (val) => { + const widgetType = getTelemetryWidgetTypeFromVS(activeSection); + if (val !== NoLink) { + logTelemetryEvent('linkedWidget', {full: {docIdDigest: this._gristDoc.docId(), widgetType}}); + } else { + logTelemetryEvent('unlinkedWidget', {full: {docIdDigest: this._gristDoc.docId(), widgetType}}); + } + + await this._gristDoc.saveLink(val); + }); return [ this._disableIfReadonly(), cssLabel(t("DATA TABLE")), diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts index c9817b3d..4a93bc0e 100644 --- a/app/client/ui/ShareMenu.ts +++ b/app/client/ui/ShareMenu.ts @@ -3,7 +3,6 @@ import {loadUserManager} from 'app/client/lib/imports'; import {AppModel, reportError} from 'app/client/models/AppModel'; import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel'; import {docUrl, getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState'; -import {GristTooltips} from 'app/client/ui/GristTooltips'; import {downloadDocModal, makeCopy, replaceTrunkWithFork} from 'app/client/ui/MakeCopyMenu'; import {sendToDrive} from 'app/client/ui/sendToDrive'; import {hoverTooltip, withInfoTooltip} from 'app/client/ui/tooltips'; @@ -255,7 +254,7 @@ function menuWorkOnCopy(pageModel: DocPageModel) { menuText( withInfoTooltip( t("Edit without affecting the original"), - GristTooltips.workOnACopy(), + 'workOnACopy', {popupOptions: {attach: null}} ) ), diff --git a/app/client/ui/UserManager.ts b/app/client/ui/UserManager.ts index 2f2f55a4..b615608b 100644 --- a/app/client/ui/UserManager.ts +++ b/app/client/ui/UserManager.ts @@ -27,7 +27,6 @@ import {IEditableMember, IMemberSelectOption, IOrgMemberSelectOption, Resource} from 'app/client/models/UserManagerModel'; import {UserManagerModel, UserManagerModelImpl} from 'app/client/models/UserManagerModel'; import {getResourceParent, ResourceType} from 'app/client/models/UserManagerModel'; -import {GristTooltips} from 'app/client/ui/GristTooltips'; import {shadowScroll} from 'app/client/ui/shadowScroll'; import {hoverTooltip, ITooltipControl, showTransientTooltip, withInfoTooltip} from 'app/client/ui/tooltips'; import {createUserImage} from 'app/client/ui/UserImage'; @@ -187,7 +186,7 @@ function buildUserManagerModal( }), testId('um-open-access-rules'), ), - GristTooltips.openAccessRules(), + 'openAccessRules', {domArgs: [cssAccessLink.cls('')]}, ) : null diff --git a/app/client/ui/WelcomeTour.ts b/app/client/ui/WelcomeTour.ts index 40907348..047b450d 100644 --- a/app/client/ui/WelcomeTour.ts +++ b/app/client/ui/WelcomeTour.ts @@ -1,4 +1,5 @@ import { makeT } from 'app/client/lib/localization'; +import { logTelemetryEvent } from 'app/client/lib/telemetry'; import * as commands from 'app/client/components/commands'; import { urlState } from 'app/client/models/gristUrlState'; import { IOnBoardingMsg, startOnBoarding } from "app/client/ui/OnBoardingPopups"; @@ -100,7 +101,15 @@ export function getOnBoardingMessages(): IOnBoardingMsg[] { export function startWelcomeTour(onFinishCB: () => void) { commands.allCommands.fieldTabOpen.run(); - startOnBoarding(getOnBoardingMessages(), onFinishCB); + const messages = getOnBoardingMessages(); + startOnBoarding(messages, (lastMessageIndex) => { + logTelemetryEvent('viewedWelcomeTour', { + full: { + percentComplete: Math.floor(((lastMessageIndex + 1) / messages.length) * 100), + }, + }); + onFinishCB(); + }); } const TopBarButtonIcon = styled(icon, ` diff --git a/app/client/ui/tooltips.ts b/app/client/ui/tooltips.ts index a8fe4a7c..4398a52f 100644 --- a/app/client/ui/tooltips.ts +++ b/app/client/ui/tooltips.ts @@ -5,6 +5,8 @@ * - to be shown briefly, as a transient notification next to some action element. */ +import {logTelemetryEvent} from 'app/client/lib/telemetry'; +import {GristTooltips, Tooltip} from 'app/client/ui/GristTooltips'; import {prepareForTransition} from 'app/client/ui/transitions'; import {testId, theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; @@ -312,6 +314,8 @@ export interface InfoTooltipOptions { variant?: InfoTooltipVariant; /** Only applicable to the `click` variant. */ popupOptions?: IPopupOptions; + /** Only applicable to the `click` variant. */ + onOpen?: () => void; } export type InfoTooltipVariant = 'click' | 'hover'; @@ -320,33 +324,42 @@ export type InfoTooltipVariant = 'click' | 'hover'; * Renders an info icon that shows a tooltip with the specified `content`. */ export function infoTooltip( - content: DomContents, + tooltip: Tooltip, options: InfoTooltipOptions = {}, ...domArgs: DomElementArg[] ) { const {variant = 'click'} = options; + const content = GristTooltips[tooltip](); + const onOpen = () => logTelemetryEvent('viewedTip', {full: {tipName: tooltip}}); switch (variant) { case 'click': { const {popupOptions} = options; - return buildClickableInfoTooltip(content, popupOptions, domArgs); + return buildClickableInfoTooltip(content, {onOpen, popupOptions}, domArgs); } case 'hover': { return buildHoverableInfoTooltip(content, domArgs); } } +} +export interface ClickableInfoTooltipOptions { + popupOptions?: IPopupOptions; + onOpen?: () => void; } function buildClickableInfoTooltip( content: DomContents, - popupOptions?: IPopupOptions, + options: ClickableInfoTooltipOptions = {}, ...domArgs: DomElementArg[] ) { + const {onOpen, popupOptions} = options; return cssInfoTooltipButton('?', (elem) => { setPopupToCreateDom( elem, (ctl) => { + onOpen?.(); + return cssInfoTooltipPopup( cssInfoTooltipPopupCloseButton( icon('CrossSmall'), @@ -395,11 +408,13 @@ export interface WithInfoTooltipOptions { iconDomArgs?: DomElementArg[]; /** Only applicable to the `click` variant. */ popupOptions?: IPopupOptions; + onOpen?: () => void; } /** - * Wraps `domContent` with a info tooltip icon that displays the provided - * `tooltipContent` and returns the wrapped element. + * Wraps `domContent` with a info tooltip icon that displays the specified + * `tooltip` and returns the wrapped element. Tooltips are defined in + * `app/client/ui/GristTooltips.ts`. * * The tooltip button is displayed to the right of `domContents`, and displays * a popup on click by default. The popup can be dismissed by clicking away from @@ -414,20 +429,17 @@ export interface WithInfoTooltipOptions { * * Usage: * - * withInfoTooltip( - * dom('div', 'Hello World!'), - * dom('p', 'This is some text to show inside the tooltip.'), - * ) + * withInfoTooltip(dom('div', 'Hello World!'), 'selectBy') */ export function withInfoTooltip( domContents: DomContents, - tooltipContent: DomContents, + tooltip: Tooltip, options: WithInfoTooltipOptions = {}, ) { const {variant = 'click', domArgs, iconDomArgs, popupOptions} = options; return cssDomWithTooltip( domContents, - infoTooltip(tooltipContent, {variant, popupOptions}, iconDomArgs), + infoTooltip(tooltip, {variant, popupOptions}, iconDomArgs), ...(domArgs ?? []) ); } diff --git a/app/client/ui/widgetTypesMap.ts b/app/client/ui/widgetTypesMap.ts index 51c9dd6b..cbd5a48d 100644 --- a/app/client/ui/widgetTypesMap.ts +++ b/app/client/ui/widgetTypesMap.ts @@ -1,6 +1,8 @@ // the list of widget types with their labels and icons -import {IWidgetType} from "app/common/widgetTypes"; +import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec"; +import {IPageWidget} from "app/client/ui/PageWidgetPicker"; import {IconName} from "app/client/ui2018/IconList"; +import {IWidgetType} from "app/common/widgetTypes"; export const widgetTypesMap = new Map([ ['record', {label: 'Table', icon: 'TypeTable'}], @@ -22,3 +24,37 @@ export interface IWidgetTypeInfo { export function getWidgetTypes(sectionType: IWidgetType | null): IWidgetTypeInfo { return widgetTypesMap.get(sectionType || 'record') || widgetTypesMap.get('record')!; } + +export interface GetTelemetryWidgetTypeOptions { + /** Defaults to `false`. */ + isSummary?: boolean; + /** Defaults to `false`. */ + isNewTable?: boolean; +} + +export function getTelemetryWidgetTypeFromVS(vs: ViewSectionRec) { + return getTelemetryWidgetType(vs.widgetType.peek(), { + isSummary: vs.table.peek().summarySourceTable.peek() !== 0, + }); +} + +export function getTelemetryWidgetTypeFromPageWidget(widget: IPageWidget) { + return getTelemetryWidgetType(widget.type, { + isNewTable: widget.table === 'New Table', + isSummary: widget.summarize, + }); +} + +function getTelemetryWidgetType(type: IWidgetType, options: GetTelemetryWidgetTypeOptions = {}) { + let telemetryWidgetType: string | undefined = widgetTypesMap.get(type)?.label; + if (!telemetryWidgetType) { return undefined; } + + if (options.isNewTable) { + telemetryWidgetType = 'New ' + telemetryWidgetType; + } + if (options.isSummary) { + telemetryWidgetType += ' (Summary)'; + } + + return telemetryWidgetType; +} diff --git a/app/client/widgets/ConditionalStyle.ts b/app/client/widgets/ConditionalStyle.ts index 554cfe64..c17ef929 100644 --- a/app/client/widgets/ConditionalStyle.ts +++ b/app/client/widgets/ConditionalStyle.ts @@ -5,7 +5,6 @@ import {KoSaveableObservable} from 'app/client/models/modelUtil'; import {RuleOwner} from 'app/client/models/RuleOwner'; import {Style} from 'app/client/models/Styles'; import {cssFieldFormula} from 'app/client/ui/FieldConfig'; -import {GristTooltips} from 'app/client/ui/GristTooltips'; import {withInfoTooltip} from 'app/client/ui/tooltips'; import {textButton} from 'app/client/ui2018/buttons'; import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect'; @@ -78,10 +77,7 @@ export class ConditionalStyle extends Disposable { dom.on('click', () => this._ruleOwner.addEmptyRule()), dom.prop('disabled', this._disabled), ), - (this._label === t('Row Style') - ? GristTooltips.addRowConditionalStyle() - : GristTooltips.addColumnConditionalStyle() - ), + this._label === t('Row Style') ? 'addRowConditionalStyle' : 'addColumnConditionalStyle' ), dom.hide(use => use(this._ruleOwner.hasRules)) ), diff --git a/app/client/widgets/FormulaAssistant.ts b/app/client/widgets/FormulaAssistant.ts index 2a8d50df..2006dfe6 100644 --- a/app/client/widgets/FormulaAssistant.ts +++ b/app/client/widgets/FormulaAssistant.ts @@ -252,7 +252,7 @@ export class FormulaAssistant extends Disposable { private _logTelemetryEvent(event: TelemetryEvent, includeContext = false, metadata: TelemetryMetadata = {}) { logTelemetryEvent(event, { full: { - docIdDigest: this._gristDoc.docId, + docIdDigest: this._gristDoc.docId(), conversationId: this._chat.conversationId.get(), ...(!includeContext ? {} : {context: { type: 'formula', diff --git a/app/common/Telemetry.ts b/app/common/Telemetry.ts index cdae7af8..7acf07eb 100644 --- a/app/common/Telemetry.ts +++ b/app/common/Telemetry.ts @@ -1428,6 +1428,286 @@ export const TelemetryContracts: TelemetryContracts = { }, }, }, + viewedWelcomeTour: { + category: 'Tutorial', + description: 'Triggered when the Grist welcome tour is closed.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + percentComplete: { + description: 'Percentage of tour completion.', + dataType: 'number', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + }, + }, + viewedTip: { + category: 'Tutorial', + description: 'Triggered when a tip is shown.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + tipName: { + description: 'The name of the tip.', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + }, + }, + deletedDoc: { + category: 'DocumentUsage', + description: 'Triggered when a document is deleted.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + docIdDigest: { + description: 'A hash of the doc id.', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + }, + }, + addedPage: { + category: 'DocumentUsage', + description: 'Triggered when a page is added.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + docIdDigest: { + description: 'A hash of the doc id.', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + }, + }, + deletedPage: { + category: 'DocumentUsage', + description: 'Triggered when a page is deleted.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + docIdDigest: { + description: 'A hash of the doc id.', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + }, + }, + addedWidget: { + category: 'WidgetUsage', + description: 'Triggered when a widget is added.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + docIdDigest: { + description: 'A hash of the doc id.', + dataType: 'string', + }, + widgetType: { + description: 'The widget type (e.g. "Form").', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + }, + }, + deletedWidget: { + category: 'WidgetUsage', + description: 'Triggered when a widget is deleted.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + docIdDigest: { + description: 'A hash of the doc id.', + dataType: 'string', + }, + widgetType: { + description: 'The widget type (e.g. "Form").', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + }, + }, + linkedWidget: { + category: 'WidgetUsage', + description: 'Triggered when a widget is linked.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + docIdDigest: { + description: 'A hash of the doc id.', + dataType: 'string', + }, + widgetType: { + description: 'The widget type (e.g. "Form").', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + }, + }, + unlinkedWidget: { + category: 'WidgetUsage', + description: 'Triggered when a widget is unlinked.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + docIdDigest: { + description: 'A hash of the doc id.', + dataType: 'string', + }, + widgetType: { + description: 'The widget type (e.g. "Form").', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + }, + }, + publishedForm: { + category: 'WidgetUsage', + description: 'Triggered when a form is published.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + docIdDigest: { + description: 'A hash of the doc id.', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + }, + }, + unpublishedForm: { + category: 'WidgetUsage', + description: 'Triggered when a form is unpublished.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + docIdDigest: { + description: 'A hash of the doc id.', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + }, + }, + visitedForm: { + category: 'WidgetUsage', + description: 'Triggered when a published form is visited.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + docIdDigest: { + description: 'A hash of the doc id.', + dataType: 'string', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + }, + }, + changedAccessRules: { + category: 'AccessRules', + description: 'Triggered when a change to access rules is saved.', + minimumTelemetryLevel: Level.full, + retentionPeriod: 'indefinitely', + metadataContracts: { + docIdDigest: { + description: 'A hash of the doc id.', + dataType: 'string', + }, + ruleCount: { + description: 'The number of access rules in the document.', + dataType: 'number', + }, + userId: { + description: 'The id of the user that triggered this event.', + dataType: 'number', + }, + altSessionId: { + description: 'A random, session-based identifier for the user that triggered this event.', + dataType: 'string', + }, + }, + }, }; type TelemetryContracts = Record; @@ -1484,6 +1764,19 @@ export const TelemetryEvents = StringUnion( 'createdDoc-FileImport', 'createdDoc-CopyTemplate', 'createdDoc-CopyDoc', + 'viewedWelcomeTour', + 'viewedTip', + 'deletedDoc', + 'addedPage', + 'deletedPage', + 'addedWidget', + 'deletedWidget', + 'linkedWidget', + 'unlinkedWidget', + 'publishedForm', + 'unpublishedForm', + 'visitedForm', + 'changedAccessRules', ); export type TelemetryEvent = typeof TelemetryEvents.type; @@ -1496,7 +1789,9 @@ type TelemetryEventCategory = | 'SubscriptionPlan' | 'DocumentUsage' | 'TeamSite' - | 'ProductVisits'; + | 'ProductVisits' + | 'AccessRules' + | 'WidgetUsage'; interface TelemetryEventContract { description: string; diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts index c778fb30..7ba59a54 100644 --- a/app/server/lib/AppEndpoint.ts +++ b/app/server/lib/AppEndpoint.ts @@ -233,7 +233,7 @@ export function attachAppEndpoint(options: AttachOptions): void { const response = await fetch(formUrl, { headers: getTransitiveHeaders(req), }); - if (response.status === 200) { + if (response.ok) { const html = await response.text(); res.send(html); } else { diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index aa6fb833..64ce7ca6 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -1540,6 +1540,13 @@ export class DocWorkerApi { SUCCESS_URL: redirectUrl, TITLE: `${section.title || tableName || tableId || 'Form'} - Grist` }); + this._grist.getTelemetry().logEvent(req, 'visitedForm', { + full: { + docIdDigest: activeDoc.docName, + userId: req.userId, + altSessionId: req.altSessionId, + }, + }); res.status(200).send(renderedHtml); }) ); @@ -2026,6 +2033,7 @@ export class DocWorkerApi { } private async _removeDoc(req: Request, res: Response, permanent: boolean) { + const mreq = req as RequestWithLogin; const scope = getDocScope(req); const docId = getDocId(req); if (permanent) { @@ -2045,6 +2053,13 @@ export class DocWorkerApi { // Permanently delete from database. const query = await this._dbManager.deleteDocument(scope); this._dbManager.checkQueryResult(query); + this._grist.getTelemetry().logEvent(mreq, 'deletedDoc', { + full: { + docIdDigest: docId, + userId: mreq.userId, + altSessionId: mreq.altSessionId, + }, + }); await sendReply(req, res, query); } else { await this._dbManager.softDeleteDocument(scope);