mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Add Support Grist page and nudge
Summary: Adds a new Support Grist page (accessible only in grist-core), containing options to opt in to telemetry and sponsor Grist Labs on GitHub. A nudge is also shown in the doc menu, which can be collapsed or permanently dismissed. Test Plan: Browser and server tests. Reviewers: paulfitz, dsagal Reviewed By: paulfitz Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D3926
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
import {AccountPage} from 'app/client/ui/AccountPage';
|
||||
import {setupPage} from 'app/client/ui/setupPage';
|
||||
import {dom} from 'grainjs';
|
||||
|
||||
setupPage((appModel) => dom.create(AccountPage, appModel));
|
||||
@@ -1,5 +0,0 @@
|
||||
import {ActivationPage} from 'app/client/ui/ActivationPage';
|
||||
import {setupPage} from 'app/client/ui/setupPage';
|
||||
import {dom} from 'grainjs';
|
||||
|
||||
setupPage((appModel) => dom.create(ActivationPage, appModel));
|
||||
@@ -83,21 +83,7 @@ export class BehavioralPromptsManager extends Disposable {
|
||||
}
|
||||
|
||||
private _queueTip(refElement: Element, prompt: BehavioralPrompt, options: AttachOptions) {
|
||||
if (
|
||||
this._isDisabled ||
|
||||
// Don't show tips if surveying is disabled.
|
||||
// TODO: Move this into a dedicated variable - this is only a short-term fix for hiding
|
||||
// tips in grist-core.
|
||||
(!getGristConfig().survey && prompt !== 'rickRow') ||
|
||||
// Or if this tip shouldn't be shown on mobile.
|
||||
(isNarrowScreen() && !options.showOnMobile) ||
|
||||
// Or if "Don't show tips" was checked in the past.
|
||||
(this._prefs.get().dontShowTips && !options.forceShow) ||
|
||||
// Or if this tip has been shown and dismissed in the past.
|
||||
this.hasSeenTip(prompt)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!this._shouldQueueTip(prompt, options)) { return; }
|
||||
|
||||
this._queuedTips.push({prompt, refElement, options});
|
||||
if (this._queuedTips.length > 1) {
|
||||
@@ -156,4 +142,26 @@ export class BehavioralPromptsManager extends Disposable {
|
||||
this._prefs.set({...this._prefs.get(), dontShowTips: true});
|
||||
this._queuedTips = [];
|
||||
}
|
||||
|
||||
private _shouldQueueTip(prompt: BehavioralPrompt, options: AttachOptions) {
|
||||
if (
|
||||
this._isDisabled ||
|
||||
(isNarrowScreen() && !options.showOnMobile) ||
|
||||
(this._prefs.get().dontShowTips && !options.forceShow) ||
|
||||
this.hasSeenTip(prompt)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {deploymentType} = getGristConfig();
|
||||
const {deploymentTypes} = GristBehavioralPrompts[prompt];
|
||||
if (
|
||||
deploymentTypes !== '*' &&
|
||||
(!deploymentType || !deploymentTypes.includes(deploymentType))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
6
app/client/lib/imports.d.ts
vendored
6
app/client/lib/imports.d.ts
vendored
@@ -1,4 +1,7 @@
|
||||
import * as AccountPageModule from 'app/client/ui/AccountPage';
|
||||
import * as ActivationPageModule from 'app/client/ui/ActivationPage';
|
||||
import * as BillingPageModule from 'app/client/ui/BillingPage';
|
||||
import * as SupportGristPageModule from 'app/client/ui/SupportGristPage';
|
||||
import * as GristDocModule from 'app/client/components/GristDoc';
|
||||
import * as ViewPane from 'app/client/components/ViewPane';
|
||||
import * as UserManagerModule from 'app/client/ui/UserManager';
|
||||
@@ -9,7 +12,10 @@ import * as plotly from 'plotly.js';
|
||||
export type PlotlyType = typeof plotly;
|
||||
export type MomentTimezone = typeof momentTimezone;
|
||||
|
||||
export function loadAccountPage(): Promise<typeof AccountPageModule>;
|
||||
export function loadActivationPage(): Promise<typeof ActivationPageModule>;
|
||||
export function loadBillingPage(): Promise<typeof BillingPageModule>;
|
||||
export function loadSupportGristPage(): Promise<typeof SupportGristPageModule>;
|
||||
export function loadGristDoc(): Promise<typeof GristDocModule>;
|
||||
export function loadMomentTimezone(): Promise<MomentTimezone>;
|
||||
export function loadPlotly(): Promise<PlotlyType>;
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
*
|
||||
*/
|
||||
|
||||
exports.loadAccountPage = () => import('app/client/ui/AccountPage' /* webpackChunkName: "AccountPage" */);
|
||||
exports.loadActivationPage = () => import('app/client/ui/ActivationPage' /* webpackChunkName: "ActivationPage" */);
|
||||
exports.loadBillingPage = () => import('app/client/ui/BillingPage' /* webpackChunkName: "BillingModule" */);
|
||||
exports.loadSupportGristPage = () => import('app/client/ui/SupportGristPage' /* webpackChunkName: "SupportGristPage" */);
|
||||
exports.loadGristDoc = () => import('app/client/components/GristDoc' /* webpackChunkName: "GristDoc" */);
|
||||
// When importing this way, the module is under the "default" member, not sure why (maybe
|
||||
// esbuild-loader's doing).
|
||||
|
||||
@@ -9,6 +9,7 @@ import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {Notifier} from 'app/client/models/NotifyModel';
|
||||
import {getFlavor, ProductFlavor} from 'app/client/ui/CustomThemes';
|
||||
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
|
||||
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
|
||||
import {attachCssThemeVars, prefersDarkModeObs} from 'app/client/ui2018/cssVars';
|
||||
import {OrgUsageSummary} from 'app/common/DocUsage';
|
||||
import {Features, isLegacyPlan, Product} from 'app/common/Features';
|
||||
@@ -31,7 +32,15 @@ const t = makeT('AppModel');
|
||||
// Reexported for convenience.
|
||||
export {reportError} from 'app/client/models/errors';
|
||||
|
||||
export type PageType = "doc" | "home" | "billing" | "welcome";
|
||||
export type PageType =
|
||||
| "doc"
|
||||
| "home"
|
||||
| "billing"
|
||||
| "welcome"
|
||||
| "account"
|
||||
| "support-grist"
|
||||
| "activation";
|
||||
|
||||
const G = getBrowserGlobals('document', 'window');
|
||||
|
||||
// TopAppModel is the part of the app model that persists across org and user switches.
|
||||
@@ -107,6 +116,8 @@ export interface AppModel {
|
||||
|
||||
behavioralPromptsManager: BehavioralPromptsManager;
|
||||
|
||||
supportGristNudge: SupportGristNudge;
|
||||
|
||||
refreshOrgUsage(): Promise<void>;
|
||||
showUpgradeModal(): void;
|
||||
showNewSiteModal(): void;
|
||||
@@ -253,7 +264,23 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
|
||||
// Get the current PageType from the URL.
|
||||
public readonly pageType: Observable<PageType> = Computed.create(this, urlState().state,
|
||||
(use, state) => (state.doc ? "doc" : (state.billing ? "billing" : (state.welcome ? "welcome" : "home"))));
|
||||
(_use, state) => {
|
||||
if (state.doc) {
|
||||
return 'doc';
|
||||
} else if (state.billing) {
|
||||
return 'billing';
|
||||
} else if (state.welcome) {
|
||||
return 'welcome';
|
||||
} else if (state.account) {
|
||||
return 'account';
|
||||
} else if (state.supportGrist) {
|
||||
return 'support-grist';
|
||||
} else if (state.activation) {
|
||||
return 'activation';
|
||||
} else {
|
||||
return 'home';
|
||||
}
|
||||
});
|
||||
|
||||
public readonly needsOrg: Observable<boolean> = Computed.create(
|
||||
this, urlState().state, (use, state) => {
|
||||
@@ -265,6 +292,8 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
public readonly behavioralPromptsManager: BehavioralPromptsManager =
|
||||
BehavioralPromptsManager.create(this, this);
|
||||
|
||||
public readonly supportGristNudge: SupportGristNudge = SupportGristNudge.create(this, this);
|
||||
|
||||
constructor(
|
||||
public readonly topAppModel: TopAppModel,
|
||||
public readonly currentUser: FullUser|null,
|
||||
|
||||
32
app/client/models/TelemetryModel.ts
Normal file
32
app/client/models/TelemetryModel.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {AppModel, getHomeUrl} from 'app/client/models/AppModel';
|
||||
import {TelemetryPrefs} from 'app/common/Install';
|
||||
import {InstallAPI, InstallAPIImpl, TelemetryPrefsWithSources} from 'app/common/InstallAPI';
|
||||
import {bundleChanges, Disposable, Observable} from 'grainjs';
|
||||
|
||||
export interface TelemetryModel {
|
||||
/** Telemetry preferences (e.g. the current telemetry level). */
|
||||
readonly prefs: Observable<TelemetryPrefsWithSources | null>;
|
||||
fetchTelemetryPrefs(): Promise<void>;
|
||||
updateTelemetryPrefs(prefs: Partial<TelemetryPrefs>): Promise<void>;
|
||||
}
|
||||
|
||||
export class TelemetryModelImpl extends Disposable implements TelemetryModel {
|
||||
public readonly prefs: Observable<TelemetryPrefsWithSources | null> = Observable.create(this, null);
|
||||
private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());
|
||||
|
||||
constructor(_appModel: AppModel) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async fetchTelemetryPrefs(): Promise<void> {
|
||||
const prefs = await this._installAPI.getInstallPrefs();
|
||||
bundleChanges(() => {
|
||||
this.prefs.set(prefs.telemetry);
|
||||
});
|
||||
}
|
||||
|
||||
public async updateTelemetryPrefs(prefs: Partial<TelemetryPrefs>): Promise<void> {
|
||||
await this._installAPI.updateInstallPrefs({telemetry: prefs});
|
||||
await this.fetchTelemetryPrefs();
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,8 @@ export class UrlStateImpl {
|
||||
*/
|
||||
public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState {
|
||||
const keepState = (newState.org || newState.ws || newState.homePage || newState.doc || isEmpty(newState) ||
|
||||
newState.account || newState.billing || newState.activation || newState.welcome) ?
|
||||
newState.account || newState.billing || newState.activation || newState.welcome ||
|
||||
newState.supportGrist) ?
|
||||
(prevState.org ? {org: prevState.org} : {}) :
|
||||
prevState;
|
||||
return {...keepState, ...newState};
|
||||
@@ -186,8 +187,11 @@ export class UrlStateImpl {
|
||||
// Reload when moving to/from the Grist sign-up page.
|
||||
const signupReload = [prevState.login, newState.login].includes('signup')
|
||||
&& prevState.login !== newState.login;
|
||||
return Boolean(orgReload || accountReload || billingReload || activationReload
|
||||
|| gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload);
|
||||
// Reload when moving to/from the support Grist page.
|
||||
const supportGristReload = Boolean(prevState.supportGrist) !== Boolean(newState.supportGrist);
|
||||
return Boolean(orgReload || accountReload || billingReload || activationReload ||
|
||||
gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload ||
|
||||
supportGristReload);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,7 +65,7 @@ export class AccountWidget extends Disposable {
|
||||
t("Toggle Mobile Mode"),
|
||||
cssCheckmark('Tick', dom.show(viewport.viewportEnabled)),
|
||||
testId('usermenu-toggle-mobile'),
|
||||
);
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return [
|
||||
@@ -100,6 +100,7 @@ export class AccountWidget extends Disposable {
|
||||
|
||||
this._maybeBuildBillingPageMenuItem(),
|
||||
this._maybeBuildActivationPageMenuItem(),
|
||||
this._maybeBuildSupportGristPageMenuItem(),
|
||||
|
||||
mobileModeToggle,
|
||||
|
||||
@@ -155,10 +156,10 @@ export class AccountWidget extends Disposable {
|
||||
// For links, disabling with just a class is hard; easier to just not make it a link.
|
||||
// TODO weasel menus should support disabling menuItemLink.
|
||||
(isBillingManager ?
|
||||
menuItemLink(urlState().setLinkUrl({billing: 'billing'}), 'Billing Account') :
|
||||
menuItem(() => null, 'Billing Account', dom.cls('disabled', true))
|
||||
menuItemLink(urlState().setLinkUrl({billing: 'billing'}), t('Billing Account')) :
|
||||
menuItem(() => null, t('Billing Account'), dom.cls('disabled', true))
|
||||
) :
|
||||
menuItem(() => this._appModel.showUpgradeModal(), 'Upgrade Plan');
|
||||
menuItem(() => this._appModel.showUpgradeModal(), t('Upgrade Plan'));
|
||||
}
|
||||
|
||||
private _maybeBuildActivationPageMenuItem() {
|
||||
@@ -167,7 +168,21 @@ export class AccountWidget extends Disposable {
|
||||
return null;
|
||||
}
|
||||
|
||||
return menuItemLink('Activation', urlState().setLinkUrl({activation: 'activation'}));
|
||||
return menuItemLink(t('Activation'), urlState().setLinkUrl({activation: 'activation'}));
|
||||
}
|
||||
|
||||
private _maybeBuildSupportGristPageMenuItem() {
|
||||
const {deploymentType} = getGristConfig();
|
||||
if (deploymentType !== 'core') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return menuItemLink(
|
||||
t('Support Grist'),
|
||||
cssHeartIcon('💛'),
|
||||
urlState().setLinkUrl({supportGrist: 'support-grist'}),
|
||||
testId('usermenu-support-grist'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +198,10 @@ export const cssUserIcon = styled('div', `
|
||||
cursor: pointer;
|
||||
`);
|
||||
|
||||
const cssHeartIcon = styled('span', `
|
||||
margin-left: 8px;
|
||||
`);
|
||||
|
||||
const cssUserInfo = styled('div', `
|
||||
padding: 12px 24px 12px 16px;
|
||||
min-width: 200px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners';
|
||||
import {ViewAsBanner} from 'app/client/components/ViewAsBanner';
|
||||
import {domAsync} from 'app/client/lib/domAsync';
|
||||
import {loadBillingPage} from 'app/client/lib/imports';
|
||||
import {loadAccountPage, loadActivationPage, loadBillingPage, loadSupportGristPage} from 'app/client/lib/imports';
|
||||
import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs';
|
||||
import {AppModel, TopAppModel} from 'app/client/models/AppModel';
|
||||
import {DocPageModelImpl} from 'app/client/models/DocPageModel';
|
||||
@@ -75,6 +75,12 @@ function createMainPage(appModel: AppModel, appObj: App) {
|
||||
return domAsync(loadBillingPage().then(bp => dom.create(bp.BillingPage, appModel)));
|
||||
} else if (pageType === 'welcome') {
|
||||
return dom.create(WelcomePage, appModel);
|
||||
} else if (pageType === 'account') {
|
||||
return domAsync(loadAccountPage().then(ap => dom.create(ap.AccountPage, appModel)));
|
||||
} else if (pageType === 'support-grist') {
|
||||
return domAsync(loadSupportGristPage().then(sgp => dom.create(sgp.SupportGristPage, appModel)));
|
||||
} else if (pageType === 'activation') {
|
||||
return domAsync(loadActivationPage().then(ap => dom.create(ap.ActivationPage, appModel)));
|
||||
} else {
|
||||
return dom.create(pagePanelsDoc, appModel, appObj);
|
||||
}
|
||||
|
||||
@@ -174,7 +174,14 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
testId('doclist')
|
||||
),
|
||||
dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
|
||||
() => upgradeButton.showUpgradeCard(css.upgradeCard.cls(''))),
|
||||
() => {
|
||||
// TODO: These don't currently clash (grist-core stubs the upgradeButton), but a way to
|
||||
// manage card popups will be needed if more are added later.
|
||||
return [
|
||||
upgradeButton.showUpgradeCard(css.upgradeCard.cls('')),
|
||||
home.app.supportGristNudge.showCard(),
|
||||
];
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {logTelemetryEvent} from 'app/client/lib/telemetry';
|
||||
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {renderer} from 'app/client/ui/DocTutorialRenderer';
|
||||
import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup';
|
||||
@@ -30,12 +31,12 @@ const TOOLTIP_KEY = 'docTutorialTooltip';
|
||||
export class DocTutorial extends FloatingPopup {
|
||||
private _appModel = this._gristDoc.docPageModel.appModel;
|
||||
private _currentDoc = this._gristDoc.docPageModel.currentDoc.get();
|
||||
private _currentFork = this._currentDoc?.forks?.[0];
|
||||
private _docComm = this._gristDoc.docComm;
|
||||
private _docData = this._gristDoc.docData;
|
||||
private _docId = this._gristDoc.docId();
|
||||
private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);
|
||||
private _currentSlideIndex = Observable.create(this,
|
||||
this._currentDoc?.forks?.[0]?.options?.tutorial?.lastSlideIndex ?? 0);
|
||||
private _currentSlideIndex = Observable.create(this, this._currentFork?.options?.tutorial?.lastSlideIndex ?? 0);
|
||||
|
||||
|
||||
private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, {
|
||||
@@ -231,14 +232,30 @@ export class DocTutorial extends FloatingPopup {
|
||||
|
||||
private async _saveCurrentSlidePosition() {
|
||||
const currentOptions = this._currentDoc?.options ?? {};
|
||||
const currentSlideIndex = this._currentSlideIndex.get();
|
||||
const numSlides = this._slides.get()?.length;
|
||||
await this._appModel.api.updateDoc(this._docId, {
|
||||
options: {
|
||||
...currentOptions,
|
||||
tutorial: {
|
||||
lastSlideIndex: this._currentSlideIndex.get(),
|
||||
lastSlideIndex: currentSlideIndex,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let percentComplete: number | undefined = undefined;
|
||||
if (numSlides !== undefined && numSlides > 0) {
|
||||
percentComplete = Math.floor(((currentSlideIndex + 1) / numSlides) * 100);
|
||||
}
|
||||
logTelemetryEvent('tutorialProgressChanged', {
|
||||
full: {
|
||||
tutorialForkIdDigest: this._currentFork?.id,
|
||||
tutorialTrunkIdDigest: this._currentFork?.trunkId,
|
||||
lastSlideIndex: currentSlideIndex,
|
||||
numSlides,
|
||||
percentComplete,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async _changeSlide(slideIndex: number) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {makeT} from 'app/client/lib/localization';
|
||||
import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {commonUrls, GristDeploymentType} from 'app/common/gristUrls';
|
||||
import {BehavioralPrompt} from 'app/common/Prefs';
|
||||
import {dom, DomContents, DomElementArg, styled} from 'grainjs';
|
||||
|
||||
@@ -104,6 +104,7 @@ export const GristTooltips: Record<Tooltip, TooltipContentFunc> = {
|
||||
export interface BehavioralPromptContent {
|
||||
title: () => string;
|
||||
content: (...domArgs: DomElementArg[]) => DomContents;
|
||||
deploymentTypes: GristDeploymentType[] | '*';
|
||||
}
|
||||
|
||||
export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptContent> = {
|
||||
@@ -119,6 +120,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
||||
),
|
||||
...args,
|
||||
),
|
||||
deploymentTypes: ['saas'],
|
||||
},
|
||||
referenceColumnsConfig: {
|
||||
title: () => t('Reference Columns'),
|
||||
@@ -133,6 +135,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
||||
),
|
||||
...args,
|
||||
),
|
||||
deploymentTypes: ['saas'],
|
||||
},
|
||||
rawDataPage: {
|
||||
title: () => t('Raw Data page'),
|
||||
@@ -142,6 +145,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
||||
dom('div', cssLink({href: commonUrls.helpRawData, target: '_blank'}, t('Learn more.'))),
|
||||
...args,
|
||||
),
|
||||
deploymentTypes: ['saas'],
|
||||
},
|
||||
accessRules: {
|
||||
title: () => t('Access Rules'),
|
||||
@@ -151,6 +155,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
||||
dom('div', cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, t('Learn more.'))),
|
||||
...args,
|
||||
),
|
||||
deploymentTypes: ['saas'],
|
||||
},
|
||||
filterButtons: {
|
||||
title: () => t('Pinning Filters'),
|
||||
@@ -160,6 +165,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
||||
dom('div', cssLink({href: commonUrls.helpFilterButtons, target: '_blank'}, t('Learn more.'))),
|
||||
...args,
|
||||
),
|
||||
deploymentTypes: ['saas'],
|
||||
},
|
||||
nestedFiltering: {
|
||||
title: () => t('Nested Filtering'),
|
||||
@@ -168,6 +174,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
||||
dom('div', t('Only those rows will appear which match all of the filters.')),
|
||||
...args,
|
||||
),
|
||||
deploymentTypes: ['saas'],
|
||||
},
|
||||
pageWidgetPicker: {
|
||||
title: () => t('Selecting Data'),
|
||||
@@ -176,6 +183,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
||||
dom('div', t('Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.')),
|
||||
...args,
|
||||
),
|
||||
deploymentTypes: ['saas'],
|
||||
},
|
||||
pageWidgetPickerSelectBy: {
|
||||
title: () => t('Linking Widgets'),
|
||||
@@ -185,6 +193,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
||||
dom('div', cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, t('Learn more.'))),
|
||||
...args,
|
||||
),
|
||||
deploymentTypes: ['saas'],
|
||||
},
|
||||
editCardLayout: {
|
||||
title: () => t('Editing Card Layout'),
|
||||
@@ -195,6 +204,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
||||
})),
|
||||
...args,
|
||||
),
|
||||
deploymentTypes: ['saas'],
|
||||
},
|
||||
addNew: {
|
||||
title: () => t('Add New'),
|
||||
@@ -203,6 +213,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
||||
+ 'or import data.')),
|
||||
...args,
|
||||
),
|
||||
deploymentTypes: ['saas'],
|
||||
},
|
||||
rickRow: {
|
||||
title: () => t('Anchor Links'),
|
||||
@@ -217,6 +228,7 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
||||
),
|
||||
...args,
|
||||
),
|
||||
deploymentTypes: '*',
|
||||
},
|
||||
customURL: {
|
||||
title: () => t('Custom Widgets'),
|
||||
@@ -230,5 +242,6 @@ export const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptCo
|
||||
dom('div', cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.'))),
|
||||
...args,
|
||||
),
|
||||
deploymentTypes: ['saas'],
|
||||
},
|
||||
};
|
||||
|
||||
326
app/client/ui/SupportGristNudge.ts
Normal file
326
app/client/ui/SupportGristNudge.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {localStorageObs} from 'app/client/lib/localStorageObs';
|
||||
import {getStorage} from 'app/client/lib/storage';
|
||||
import {tokenFieldStyles} from 'app/client/lib/TokenField';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
|
||||
import {bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-support-grist-nudge-');
|
||||
|
||||
const t = makeT('SupportGristNudge');
|
||||
|
||||
type ButtonState =
|
||||
| 'collapsed'
|
||||
| 'expanded';
|
||||
|
||||
type CardPage =
|
||||
| 'support-grist'
|
||||
| 'opted-in';
|
||||
|
||||
/**
|
||||
* Nudges users to support Grist by opting in to telemetry.
|
||||
*
|
||||
* This currently includes a button that opens a card with the nudge.
|
||||
* The button is hidden when the card is visible, and vice versa.
|
||||
*/
|
||||
export class SupportGristNudge extends Disposable {
|
||||
private readonly _telemetryModel: TelemetryModel = new TelemetryModelImpl(this._appModel);
|
||||
|
||||
private readonly _buttonState: Observable<ButtonState>;
|
||||
private readonly _currentPage: Observable<CardPage>;
|
||||
private readonly _isClosed: Observable<boolean>;
|
||||
|
||||
constructor(private _appModel: AppModel) {
|
||||
super();
|
||||
if (!this._shouldShowCardOrButton()) { return; }
|
||||
|
||||
this._buttonState = localStorageObs(
|
||||
`u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`, 'expanded'
|
||||
) as Observable<ButtonState>;
|
||||
this._currentPage = Observable.create(null, 'support-grist');
|
||||
this._isClosed = Observable.create(this, false);
|
||||
}
|
||||
|
||||
public showButton() {
|
||||
if (!this._shouldShowCardOrButton()) { return null; }
|
||||
|
||||
return dom.maybe(
|
||||
use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'collapsed' && !use(this._isClosed)),
|
||||
() => this._buildButton()
|
||||
);
|
||||
}
|
||||
|
||||
public showCard() {
|
||||
if (!this._shouldShowCardOrButton()) { return null; }
|
||||
|
||||
return dom.maybe(
|
||||
use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'expanded' && !use(this._isClosed)),
|
||||
() => this._buildCard()
|
||||
);
|
||||
}
|
||||
|
||||
private _markAsDismissed() {
|
||||
this._appModel.dismissedPopup('supportGrist').set(true);
|
||||
getStorage().removeItem(
|
||||
`u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`);
|
||||
|
||||
}
|
||||
|
||||
private _close() {
|
||||
this._isClosed.set(true);
|
||||
}
|
||||
|
||||
private _dismissAndClose() {
|
||||
this._markAsDismissed();
|
||||
this._close();
|
||||
}
|
||||
|
||||
private _shouldShowCardOrButton() {
|
||||
if (this._appModel.dismissedPopups.get().includes('supportGrist')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {activation, deploymentType, telemetry} = getGristConfig();
|
||||
if (deploymentType !== 'core' || !activation?.isManager) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (telemetry && telemetry.telemetryLevel !== 'off') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _buildButton() {
|
||||
return cssContributeButton(
|
||||
cssButtonIconAndText(
|
||||
icon('Fireworks'),
|
||||
t('Contribute'),
|
||||
),
|
||||
cssContributeButtonCloseButton(
|
||||
icon('CrossSmall'),
|
||||
dom.on('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
this._dismissAndClose();
|
||||
}),
|
||||
testId('contribute-button-close'),
|
||||
),
|
||||
dom.on('click', () => { this._buttonState.set('expanded'); }),
|
||||
testId('contribute-button'),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildCard() {
|
||||
return cssCard(
|
||||
dom.domComputed(this._currentPage, page => {
|
||||
if (page === 'support-grist') {
|
||||
return this._buildSupportGristCardContent();
|
||||
} else {
|
||||
return this._buildOptedInCardContent();
|
||||
}
|
||||
}),
|
||||
testId('card'),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildSupportGristCardContent() {
|
||||
return [
|
||||
cssCloseButton(
|
||||
icon('CrossBig'),
|
||||
dom.on('click', () => this._buttonState.set('collapsed')),
|
||||
testId('card-close'),
|
||||
),
|
||||
cssLeftAlignedHeader(t('Support Grist')),
|
||||
cssParagraph(t(
|
||||
'Opt in to telemetry to help us understand how the product ' +
|
||||
'is used, so that we can prioritize future improvements.'
|
||||
)),
|
||||
cssParagraph(
|
||||
t(
|
||||
'We only collect usage statistics, as detailed in our {{helpCenterLink}}, never ' +
|
||||
'document contents. Opt out any time from the {{supportGristLink}} in the user menu.',
|
||||
{
|
||||
helpCenterLink: helpCenterLink(),
|
||||
supportGristLink: supportGristLink(),
|
||||
},
|
||||
),
|
||||
),
|
||||
cssFullWidthButton(
|
||||
t('Opt in to Telemetry'),
|
||||
dom.on('click', () => this._optInToTelemetry()),
|
||||
testId('card-opt-in'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _buildOptedInCardContent() {
|
||||
return [
|
||||
cssCloseButton(
|
||||
icon('CrossBig'),
|
||||
dom.on('click', () => this._close()),
|
||||
testId('card-close-icon-button'),
|
||||
),
|
||||
cssCenteredFlex(cssSparks()),
|
||||
cssCenterAlignedHeader(t('Opted In')),
|
||||
cssParagraph(
|
||||
t(
|
||||
'Thank you! Your trust and support is greatly appreciated. ' +
|
||||
'Opt out any time from the {{link}} in the user menu.',
|
||||
{link: supportGristLink()},
|
||||
),
|
||||
),
|
||||
cssCenteredFlex(
|
||||
cssPrimaryButton(
|
||||
t('Close'),
|
||||
dom.on('click', () => this._close()),
|
||||
testId('card-close-button'),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private async _optInToTelemetry() {
|
||||
await this._telemetryModel.updateTelemetryPrefs({telemetryLevel: 'limited'});
|
||||
this._currentPage.set('opted-in');
|
||||
this._markAsDismissed();
|
||||
}
|
||||
}
|
||||
|
||||
function helpCenterLink() {
|
||||
return cssLink(
|
||||
t('Help Center'),
|
||||
{href: commonUrls.helpTelemetryLimited, target: '_blank'},
|
||||
);
|
||||
}
|
||||
|
||||
function supportGristLink() {
|
||||
return cssLink(
|
||||
t('Support Grist page'),
|
||||
{href: urlState().makeUrl({supportGrist: 'support-grist'}), target: '_blank'},
|
||||
);
|
||||
}
|
||||
|
||||
const cssCenteredFlex = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`);
|
||||
|
||||
const cssContributeButton = styled('div', `
|
||||
position: relative;
|
||||
background: ${theme.controlPrimaryBg};
|
||||
color: ${theme.controlPrimaryFg};
|
||||
border-radius: 25px;
|
||||
padding: 4px 12px 4px 8px;
|
||||
font-style: normal;
|
||||
font-weight: medium;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
--icon-color: ${theme.controlPrimaryFg};
|
||||
|
||||
&:hover {
|
||||
background: ${theme.controlPrimaryHoverBg};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssButtonIconAndText = styled('div', `
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`);
|
||||
|
||||
const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton, `
|
||||
margin-left: 4px;
|
||||
vertical-align: bottom;
|
||||
line-height: 1;
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -8px;
|
||||
border-radius: 16px;
|
||||
background-color: ${colors.dark};
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.${cssContributeButton.className}:hover & {
|
||||
display: flex;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssCard = styled('div', `
|
||||
width: 297px;
|
||||
padding: 24px;
|
||||
background: #DCF4EB;
|
||||
border-radius: 4px;
|
||||
align-self: flex-start;
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
top: 0px;
|
||||
`);
|
||||
|
||||
const cssHeader = styled('div', `
|
||||
font-size: ${vars.xxxlargeFontSize};
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
`);
|
||||
|
||||
const cssLeftAlignedHeader = styled(cssHeader, `
|
||||
text-align: left;
|
||||
`);
|
||||
|
||||
const cssCenterAlignedHeader = styled(cssHeader, `
|
||||
text-align: center;
|
||||
`);
|
||||
|
||||
const cssParagraph = styled('div', `
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
margin-bottom: 12px;
|
||||
`);
|
||||
|
||||
const cssPrimaryButton = styled(bigPrimaryButton, `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
`);
|
||||
|
||||
const cssFullWidthButton = styled(cssPrimaryButton, `
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
const cssCloseButton = styled('div', `
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
--icon-color: ${colors.slate};
|
||||
|
||||
&:hover {
|
||||
background-color: ${colors.mediumGreyOpaque};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSparks = styled('div', `
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
background-image: var(--icon-Sparks);
|
||||
display: inline-block;
|
||||
background-repeat: no-repeat;
|
||||
`);
|
||||
289
app/client/ui/SupportGristPage.ts
Normal file
289
app/client/ui/SupportGristPage.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import {buildHomeBanners} from 'app/client/components/Banners';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
|
||||
import {AppHeader} from 'app/client/ui/AppHeader';
|
||||
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
||||
import {createTopBarHome} from 'app/client/ui/TopBar';
|
||||
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
||||
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||
import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {Computed, Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-support-grist-page-');
|
||||
|
||||
const t = makeT('SupportGristPage');
|
||||
|
||||
export class SupportGristPage extends Disposable {
|
||||
private readonly _model: TelemetryModel = new TelemetryModelImpl(this._appModel);
|
||||
private readonly _optInToTelemetry = Computed.create(this, this._model.prefs,
|
||||
(_use, prefs) => {
|
||||
if (!prefs) { return null; }
|
||||
|
||||
return prefs.telemetryLevel.value !== 'off';
|
||||
})
|
||||
.onWrite(async (optIn) => {
|
||||
const telemetryLevel = optIn ? 'limited' : 'off';
|
||||
await this._model.updateTelemetryPrefs({telemetryLevel});
|
||||
});
|
||||
|
||||
constructor(private _appModel: AppModel) {
|
||||
super();
|
||||
this._model.fetchTelemetryPrefs().catch(reportError);
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const panelOpen = Observable.create(this, false);
|
||||
return pagePanels({
|
||||
leftPanel: {
|
||||
panelWidth: Observable.create(this, 240),
|
||||
panelOpen,
|
||||
hideOpener: true,
|
||||
header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel),
|
||||
content: leftPanelBasic(this._appModel, panelOpen),
|
||||
},
|
||||
headerMain: this._buildMainHeader(),
|
||||
contentTop: buildHomeBanners(this._appModel),
|
||||
contentMain: this._buildMainContent(),
|
||||
});
|
||||
}
|
||||
|
||||
private _buildMainHeader() {
|
||||
return dom.frag(
|
||||
cssBreadcrumbs({style: 'margin-left: 16px;'},
|
||||
cssLink(
|
||||
urlState().setLinkUrl({}),
|
||||
t('Home'),
|
||||
),
|
||||
separator(' / '),
|
||||
dom('span', t('Support Grist')),
|
||||
),
|
||||
createTopBarHome(this._appModel),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildMainContent() {
|
||||
return cssPageContainer(
|
||||
cssPage(
|
||||
dom('div',
|
||||
cssPageTitle(t('Support Grist')),
|
||||
this._buildTelemetrySection(),
|
||||
this._buildSponsorshipSection(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildTelemetrySection() {
|
||||
return cssSection(
|
||||
cssSectionTitle(t('Telemetry')),
|
||||
dom.domComputed(this._model.prefs, prefs => {
|
||||
if (prefs === null) {
|
||||
return cssSpinnerBox(loadingSpinner());
|
||||
}
|
||||
|
||||
const {activation} = getGristConfig();
|
||||
if (!activation?.isManager) {
|
||||
if (prefs.telemetryLevel.value === 'limited') {
|
||||
return [
|
||||
cssParagraph(t(
|
||||
'This instance is opted in to telemetry. Only the site administrator has permission to change this.',
|
||||
))
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
cssParagraph(t(
|
||||
'This instance is opted out of telemetry. Only the site administrator has permission to change this.',
|
||||
))
|
||||
];
|
||||
}
|
||||
} else {
|
||||
return [
|
||||
cssParagraph(t(
|
||||
'Support Grist by opting in to telemetry, which helps us understand how the product ' +
|
||||
'is used, so that we can prioritize future improvements.'
|
||||
)),
|
||||
cssParagraph(
|
||||
t('We only collect usage statistics, as detailed in our {{link}}, never document contents.', {
|
||||
link: telemetryHelpCenterLink(),
|
||||
}),
|
||||
),
|
||||
cssParagraph(t('You can opt out of telemetry at any time from this page.')),
|
||||
this._buildTelemetrySectionButtons(prefs),
|
||||
];
|
||||
}
|
||||
}),
|
||||
testId('telemetry-section'),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildTelemetrySectionButtons(prefs: TelemetryPrefsWithSources) {
|
||||
const {telemetryLevel: {value, source}} = prefs;
|
||||
if (source === 'preferences') {
|
||||
return dom.domComputed(this._optInToTelemetry, (optedIn) => {
|
||||
if (optedIn) {
|
||||
return [
|
||||
cssOptInOutMessage(
|
||||
t('You have opted in to telemetry. Thank you!'), ' 🙏',
|
||||
testId('telemetry-section-message'),
|
||||
),
|
||||
cssOptOutButton(t('Opt out of Telemetry'),
|
||||
dom.on('click', () => this._optInToTelemetry.set(false)),
|
||||
),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
cssOptInButton(t('Opt in to Telemetry'),
|
||||
dom.on('click', () => this._optInToTelemetry.set(true)),
|
||||
),
|
||||
];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return cssOptInOutMessage(
|
||||
value !== 'off'
|
||||
? [t('You have opted in to telemetry. Thank you!'), ' 🙏']
|
||||
: t('You have opted out of telemetry.'),
|
||||
testId('telemetry-section-message'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _buildSponsorshipSection() {
|
||||
return cssSection(
|
||||
cssSectionTitle(t('Sponsor Grist Labs on GitHub')),
|
||||
cssParagraph(
|
||||
t(
|
||||
'Grist software is developed by Grist Labs, which offers free and paid ' +
|
||||
'hosted plans. We also make Grist code available under a standard free ' +
|
||||
'and open OSS license (Apache 2.0) on {{link}}.',
|
||||
{link: gristCoreLink()},
|
||||
),
|
||||
),
|
||||
cssParagraph(
|
||||
t(
|
||||
'You can support Grist open-source development by sponsoring ' +
|
||||
'us on our {{link}}.',
|
||||
{link: sponsorGristLink()},
|
||||
),
|
||||
),
|
||||
cssParagraph(t(
|
||||
'We are a small and determined team. Your support matters a lot to us. ' +
|
||||
'It also shows to others that there is a determined community behind this product.'
|
||||
)),
|
||||
cssSponsorButton(
|
||||
cssButtonIconAndText(icon('Heart'), cssButtonText(t('Manage Sponsorship'))),
|
||||
{href: commonUrls.githubSponsorGristLabs, target: '_blank'},
|
||||
),
|
||||
testId('sponsorship-section'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function telemetryHelpCenterLink() {
|
||||
return cssLink(
|
||||
t('Help Center'),
|
||||
{href: commonUrls.helpTelemetryLimited, target: '_blank'},
|
||||
);
|
||||
}
|
||||
|
||||
function sponsorGristLink() {
|
||||
return cssLink(
|
||||
t('GitHub Sponsors page'),
|
||||
{href: commonUrls.githubSponsorGristLabs, target: '_blank'},
|
||||
);
|
||||
}
|
||||
|
||||
function gristCoreLink() {
|
||||
return cssLink(
|
||||
t('GitHub'),
|
||||
{href: commonUrls.githubGristCore, target: '_blank'},
|
||||
);
|
||||
}
|
||||
|
||||
const cssPageContainer = styled('div', `
|
||||
overflow: auto;
|
||||
padding: 64px 80px;
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPage = styled('div', `
|
||||
padding: 16px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
const cssPageTitle = styled('div', `
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
margin-bottom: 24px;
|
||||
color: ${theme.text};
|
||||
font-size: 24px;
|
||||
font-weight: ${vars.headerControlTextWeight};
|
||||
`);
|
||||
|
||||
const cssSectionTitle = styled('div', `
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
margin-bottom: 24px;
|
||||
color: ${theme.text};
|
||||
font-size: ${vars.xlargeFontSize};
|
||||
font-weight: ${vars.headerControlTextWeight};
|
||||
`);
|
||||
|
||||
const cssSection = styled('div', `
|
||||
margin-bottom: 60px;
|
||||
`);
|
||||
|
||||
const cssParagraph = styled('div', `
|
||||
color: ${theme.text};
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
margin-bottom: 12px;
|
||||
`);
|
||||
|
||||
const cssOptInOutMessage = styled(cssParagraph, `
|
||||
line-height: 40px;
|
||||
font-weight: 600;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 0px;
|
||||
`);
|
||||
|
||||
const cssOptInButton = styled(bigPrimaryButton, `
|
||||
margin-top: 24px;
|
||||
`);
|
||||
|
||||
const cssOptOutButton = styled(bigBasicButton, `
|
||||
margin-top: 24px;
|
||||
`);
|
||||
|
||||
const cssSponsorButton = styled(bigBasicButtonLink, `
|
||||
margin-top: 24px;
|
||||
`);
|
||||
|
||||
const cssButtonIconAndText = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`);
|
||||
|
||||
const cssButtonText = styled('span', `
|
||||
margin-left: 8px;
|
||||
`);
|
||||
|
||||
const cssSpinnerBox = styled('div', `
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
`);
|
||||
@@ -26,7 +26,7 @@ const t = makeT('TopBar');
|
||||
export function createTopBarHome(appModel: AppModel) {
|
||||
return [
|
||||
cssFlexSpace(),
|
||||
|
||||
appModel.supportGristNudge.showButton(),
|
||||
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
|
||||
[
|
||||
basicButton(
|
||||
|
||||
@@ -72,6 +72,7 @@ export type IconName = "ChartArea" |
|
||||
"FunctionResult" |
|
||||
"GreenArrow" |
|
||||
"Grow" |
|
||||
"Heart" |
|
||||
"Help" |
|
||||
"Home" |
|
||||
"Idea" |
|
||||
@@ -214,6 +215,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"FunctionResult",
|
||||
"GreenArrow",
|
||||
"Grow",
|
||||
"Heart",
|
||||
"Help",
|
||||
"Home",
|
||||
"Idea",
|
||||
|
||||
10
app/common/Install.ts
Normal file
10
app/common/Install.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import {TelemetryLevel} from 'app/common/Telemetry';
|
||||
|
||||
export interface InstallPrefs {
|
||||
telemetry?: TelemetryPrefs;
|
||||
}
|
||||
|
||||
export interface TelemetryPrefs {
|
||||
/** Defaults to "off". */
|
||||
telemetryLevel?: TelemetryLevel;
|
||||
}
|
||||
51
app/common/InstallAPI.ts
Normal file
51
app/common/InstallAPI.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
|
||||
import {InstallPrefs} from 'app/common/Install';
|
||||
import {TelemetryLevel} from 'app/common/Telemetry';
|
||||
import {addCurrentOrgToPath} from 'app/common/urlUtils';
|
||||
|
||||
export const installPropertyKeys = ['prefs'];
|
||||
|
||||
export interface InstallProperties {
|
||||
prefs: InstallPrefs;
|
||||
}
|
||||
|
||||
export interface InstallPrefsWithSources {
|
||||
telemetry: {
|
||||
telemetryLevel: PrefWithSource<TelemetryLevel>;
|
||||
},
|
||||
}
|
||||
|
||||
export type TelemetryPrefsWithSources = InstallPrefsWithSources['telemetry'];
|
||||
|
||||
export interface PrefWithSource<T> {
|
||||
value: T;
|
||||
source: PrefSource;
|
||||
}
|
||||
|
||||
export type PrefSource = 'environment-variable' | 'preferences';
|
||||
|
||||
export interface InstallAPI {
|
||||
getInstallPrefs(): Promise<InstallPrefsWithSources>;
|
||||
updateInstallPrefs(prefs: Partial<InstallPrefs>): Promise<void>;
|
||||
}
|
||||
|
||||
export class InstallAPIImpl extends BaseAPI implements InstallAPI {
|
||||
constructor(private _homeUrl: string, options: IOptions = {}) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
public async getInstallPrefs(): Promise<InstallPrefsWithSources> {
|
||||
return this.requestJson(`${this._url}/api/install/prefs`, {method: 'GET'});
|
||||
}
|
||||
|
||||
public async updateInstallPrefs(prefs: Partial<InstallPrefs>): Promise<void> {
|
||||
await this.request(`${this._url}/api/install/prefs`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({...prefs}),
|
||||
});
|
||||
}
|
||||
|
||||
private get _url(): string {
|
||||
return addCurrentOrgToPath(this._homeUrl);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,7 @@ export const DismissedPopup = StringUnion(
|
||||
'tutorialFirstCard', // first card of the tutorial,
|
||||
'formulaHelpInfo', // formula help info shown in the popup editor,
|
||||
'formulaAssistantInfo', // formula assistant info shown in the popup editor,
|
||||
'supportGrist', // nudge to opt in to telemetry,
|
||||
);
|
||||
export type DismissedPopup = typeof DismissedPopup.type;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {StringUnion} from 'app/common/StringUnion';
|
||||
import pickBy = require('lodash/pickBy');
|
||||
|
||||
/**
|
||||
* Telemetry levels, in increasing order of data collected.
|
||||
@@ -720,36 +719,3 @@ export function buildTelemetryEventChecker(telemetryLevel: TelemetryLevel) {
|
||||
}
|
||||
|
||||
export type TelemetryEventChecker = (event: TelemetryEvent, metadata?: TelemetryMetadata) => void;
|
||||
|
||||
/**
|
||||
* Returns a new, filtered metadata object.
|
||||
*
|
||||
* Metadata in groups that don't meet `telemetryLevel` are removed from the
|
||||
* returned object, and the returned object is flattened.
|
||||
*
|
||||
* Returns undefined if `metadata` is undefined.
|
||||
*/
|
||||
export function filterMetadata(
|
||||
metadata: TelemetryMetadataByLevel | undefined,
|
||||
telemetryLevel: TelemetryLevel
|
||||
): TelemetryMetadata | undefined {
|
||||
if (!metadata) { return; }
|
||||
|
||||
let filteredMetadata = {};
|
||||
for (const level of ['limited', 'full'] as const) {
|
||||
if (Level[telemetryLevel] < Level[level]) { break; }
|
||||
|
||||
filteredMetadata = {...filteredMetadata, ...metadata[level]};
|
||||
}
|
||||
|
||||
filteredMetadata = removeNullishKeys(filteredMetadata);
|
||||
|
||||
return removeNullishKeys(filteredMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of `object` with all null and undefined keys removed.
|
||||
*/
|
||||
export function removeNullishKeys(object: Record<string, any>) {
|
||||
return pickBy(object, value => value !== null && value !== undefined);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ export type ActivationPage = typeof ActivationPage.type;
|
||||
export const LoginPage = StringUnion('signup', 'login', 'verified', 'forgot-password');
|
||||
export type LoginPage = typeof LoginPage.type;
|
||||
|
||||
export const SupportGristPage = StringUnion('support-grist');
|
||||
export type SupportGristPage = typeof SupportGristPage.type;
|
||||
|
||||
// Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience.
|
||||
export const InterfaceStyle = StringUnion('light', 'full');
|
||||
export type InterfaceStyle = typeof InterfaceStyle.type;
|
||||
@@ -72,6 +75,7 @@ export const commonUrls = {
|
||||
helpTriggerFormulas: "https://support.getgrist.com/formulas/#trigger-formulas",
|
||||
helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes",
|
||||
helpCustomWidgets: "https://support.getgrist.com/widget-custom",
|
||||
helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited",
|
||||
plans: "https://www.getgrist.com/pricing",
|
||||
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
||||
contact: "https://www.getgrist.com/contact",
|
||||
@@ -83,6 +87,8 @@ export const commonUrls = {
|
||||
basicTutorialImage: 'https://www.getgrist.com/wp-content/uploads/2021/08/lightweight-crm.png',
|
||||
gristLabsCustomWidgets: 'https://gristlabs.github.io/grist-widget/',
|
||||
gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json',
|
||||
githubGristCore: 'https://github.com/gristlabs/grist-core',
|
||||
githubSponsorGristLabs: 'https://github.com/sponsors/gristlabs',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -103,6 +109,7 @@ export interface IGristUrlState {
|
||||
activation?: ActivationPage;
|
||||
login?: LoginPage;
|
||||
welcome?: WelcomePage;
|
||||
supportGrist?: SupportGristPage;
|
||||
welcomeTour?: boolean;
|
||||
docTour?: boolean;
|
||||
manageUsers?: boolean;
|
||||
@@ -258,6 +265,8 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
|
||||
parts.push(`welcome/${state.welcome}`);
|
||||
}
|
||||
|
||||
if (state.supportGrist) { parts.push(state.supportGrist); }
|
||||
|
||||
const queryParams = pickBy(state.params, (v, k) => k !== 'linkParameters') as {[key: string]: string};
|
||||
for (const [k, v] of Object.entries(state.params?.linkParameters || {})) {
|
||||
queryParams[`${k}_`] = v;
|
||||
@@ -320,7 +329,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
|
||||
// the minimum length of a urlId prefix is longer than the maximum length
|
||||
// of any of the valid keys in the url.
|
||||
for (const key of map.keys()) {
|
||||
if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key)) {
|
||||
if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key) && !SupportGristPage.guard(key)) {
|
||||
map.set('doc', key);
|
||||
map.set('slug', map.get(key)!);
|
||||
map.delete(key);
|
||||
@@ -358,6 +367,9 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
|
||||
state.activation = ActivationPage.parse(map.get('activation')) || 'activation';
|
||||
}
|
||||
if (map.has('welcome')) { state.welcome = WelcomePage.parse(map.get('welcome')); }
|
||||
if (map.has('support-grist')) {
|
||||
state.supportGrist = SupportGristPage.parse(map.get('support-grist')) || 'support-grist';
|
||||
}
|
||||
if (sp.has('planType')) { state.params!.planType = sp.get('planType')!; }
|
||||
if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; }
|
||||
if (sp.has('billingTask')) {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {InstallPrefs} from "app/common/Install";
|
||||
import {ApiError} from "app/common/ApiError";
|
||||
import {InstallProperties, installPropertyKeys} from "app/common/InstallAPI";
|
||||
import {nativeValues} from "app/gen-server/lib/values";
|
||||
import {BaseEntity, Column, Entity, PrimaryColumn} from "typeorm";
|
||||
|
||||
@Entity({name: 'activations'})
|
||||
@@ -9,9 +13,47 @@ export class Activation extends BaseEntity {
|
||||
@Column({name: 'key', type: 'text', nullable: true})
|
||||
public key: string|null;
|
||||
|
||||
@Column({type: nativeValues.jsonEntityType, nullable: true})
|
||||
public prefs: InstallPrefs|null;
|
||||
|
||||
@Column({name: 'created_at', default: () => "CURRENT_TIMESTAMP"})
|
||||
public createdAt: Date;
|
||||
|
||||
@Column({name: 'updated_at', default: () => "CURRENT_TIMESTAMP"})
|
||||
public updatedAt: Date;
|
||||
|
||||
public checkProperties(props: any): props is Partial<InstallProperties> {
|
||||
for (const key of Object.keys(props)) {
|
||||
if (!installPropertyKeys.includes(key)) {
|
||||
throw new ApiError(`Unrecognized property ${key}`, 400);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public updateFromProperties(props: Partial<InstallProperties>) {
|
||||
if (props.prefs === undefined) { return; }
|
||||
|
||||
if (props.prefs === null) {
|
||||
this.prefs = null;
|
||||
} else {
|
||||
this.prefs = this.prefs || {};
|
||||
if (props.prefs.telemetry !== undefined) {
|
||||
this.prefs.telemetry = this.prefs.telemetry || {};
|
||||
if (props.prefs.telemetry.telemetryLevel !== undefined) {
|
||||
this.prefs.telemetry.telemetryLevel = props.prefs.telemetry.telemetryLevel;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(this.prefs) as Array<keyof InstallPrefs>) {
|
||||
if (this.prefs[key] === null) {
|
||||
delete this.prefs[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(this.prefs).length === 0) {
|
||||
this.prefs = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {ApiError} from 'app/common/ApiError';
|
||||
import {DocumentUsage} from 'app/common/DocUsage';
|
||||
import {hashId} from 'app/common/hashingUtils';
|
||||
import {Role} from 'app/common/roles';
|
||||
import {DocumentOptions, DocumentProperties, documentPropertyKeys,
|
||||
DocumentType, NEW_DOCUMENT_CODE, TutorialMetadata} from "app/common/UserAPI";
|
||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||
import {DocumentOptions, DocumentProperties, documentPropertyKeys, DocumentType,
|
||||
NEW_DOCUMENT_CODE} from "app/common/UserAPI";
|
||||
import {nativeValues} from 'app/gen-server/lib/values';
|
||||
import {Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryColumn} from "typeorm";
|
||||
import {AclRuleDoc} from "./AclRule";
|
||||
@@ -92,7 +90,7 @@ export class Document extends Resource {
|
||||
return super.checkProperties(props, documentPropertyKeys);
|
||||
}
|
||||
|
||||
public updateFromProperties(props: Partial<DocumentProperties>, dbManager?: HomeDBManager) {
|
||||
public updateFromProperties(props: Partial<DocumentProperties>) {
|
||||
super.updateFromProperties(props);
|
||||
if (props.isPinned !== undefined) { this.isPinned = props.isPinned; }
|
||||
if (props.urlId !== undefined) {
|
||||
@@ -135,9 +133,6 @@ export class Document extends Resource {
|
||||
}
|
||||
if (props.options.tutorial.lastSlideIndex !== undefined) {
|
||||
this.options.tutorial.lastSlideIndex = props.options.tutorial.lastSlideIndex;
|
||||
if (dbManager && this.options.tutorial.numSlides) {
|
||||
this._emitTutorialProgressChangeEvent(dbManager, this.options.tutorial);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,26 +149,6 @@ export class Document extends Resource {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _emitTutorialProgressChangeEvent(
|
||||
dbManager: HomeDBManager,
|
||||
tutorialMetadata: TutorialMetadata
|
||||
) {
|
||||
const lastSlideIndex = tutorialMetadata.lastSlideIndex;
|
||||
const numSlides = tutorialMetadata.numSlides;
|
||||
const percentComplete = lastSlideIndex !== undefined && numSlides !== undefined
|
||||
? Math.floor((lastSlideIndex / numSlides) * 100)
|
||||
: undefined;
|
||||
dbManager?.emit('tutorialProgressChanged', {
|
||||
full: {
|
||||
tutorialForkIdDigest: hashId(this.id),
|
||||
tutorialTrunkIdDigest: this.trunkId ? hashId(this.trunkId) : undefined,
|
||||
lastSlideIndex,
|
||||
numSlides,
|
||||
percentComplete,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check that icon points to an expected location. This will definitely
|
||||
|
||||
@@ -20,6 +20,7 @@ export class Activations {
|
||||
if (!activation) {
|
||||
activation = manager.create(Activation);
|
||||
activation.id = makeId();
|
||||
activation.prefs = {};
|
||||
await activation.save();
|
||||
}
|
||||
return activation;
|
||||
|
||||
@@ -89,14 +89,6 @@ export const NotifierEvents = StringUnion(
|
||||
|
||||
export type NotifierEvent = typeof NotifierEvents.type;
|
||||
|
||||
export const HomeDBTelemetryEvents = StringUnion(
|
||||
'tutorialProgressChanged',
|
||||
);
|
||||
|
||||
export type HomeDBTelemetryEvent = typeof HomeDBTelemetryEvents.type;
|
||||
|
||||
export type Event = NotifierEvent | HomeDBTelemetryEvent;
|
||||
|
||||
// Nominal email address of a user who can view anything (for thumbnails).
|
||||
export const PREVIEWER_EMAIL = 'thumbnail@getgrist.com';
|
||||
|
||||
@@ -324,7 +316,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
orgOnly: true
|
||||
}];
|
||||
|
||||
public emit(event: Event, ...args: any[]): boolean {
|
||||
public emit(event: NotifierEvent, ...args: any[]): boolean {
|
||||
return super.emit(event, ...args);
|
||||
}
|
||||
|
||||
@@ -1960,7 +1952,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
// Update the name and save.
|
||||
const doc: Document = queryResult.data;
|
||||
doc.checkProperties(props);
|
||||
doc.updateFromProperties(props, this);
|
||||
doc.updateFromProperties(props);
|
||||
if (forkId) {
|
||||
await manager.save(doc);
|
||||
return {status: 200};
|
||||
|
||||
16
app/gen-server/migration/1682636695021-ActivationPrefs.ts
Normal file
16
app/gen-server/migration/1682636695021-ActivationPrefs.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {nativeValues} from 'app/gen-server/lib/values';
|
||||
import {MigrationInterface, QueryRunner, TableColumn} from 'typeorm';
|
||||
|
||||
export class ActivationPrefs1682636695021 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.addColumn('activations', new TableColumn({
|
||||
name: 'prefs',
|
||||
type: nativeValues.jsonType,
|
||||
isNullable: true,
|
||||
}));
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropColumn('activations', 'prefs');
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { applyPatch } from 'app/gen-server/lib/TypeORMPatches';
|
||||
import { getMigrations, getOrCreateConnection, getTypeORMSettings,
|
||||
undoLastMigration, updateDb } from 'app/server/lib/dbUtils';
|
||||
import { getDatabaseUrl } from 'app/server/lib/serverUtils';
|
||||
import { getTelemetryLevel } from 'app/server/lib/Telemetry';
|
||||
import { getTelemetryPrefs } from 'app/server/lib/Telemetry';
|
||||
import { Gristifier } from 'app/server/utils/gristify';
|
||||
import { pruneActionHistory } from 'app/server/utils/pruneActionHistory';
|
||||
import * as commander from 'commander';
|
||||
@@ -81,12 +81,14 @@ export function addSettingsCommand(program: commander.Command,
|
||||
.action(showTelemetry);
|
||||
}
|
||||
|
||||
function showTelemetry(options: {
|
||||
async function showTelemetry(options: {
|
||||
json?: boolean,
|
||||
all?: boolean,
|
||||
}) {
|
||||
const contracts = TelemetryContracts;
|
||||
const levelName = getTelemetryLevel();
|
||||
const db = await getHomeDBManager();
|
||||
const prefs = await getTelemetryPrefs(db);
|
||||
const levelName = prefs.telemetryLevel.value;
|
||||
const level = Level[levelName];
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -68,7 +68,6 @@ import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccess
|
||||
import {isHiddenCol} from 'app/common/gristTypes';
|
||||
import {commonUrls, parseUrlId} from 'app/common/gristUrls';
|
||||
import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil';
|
||||
import {hashId} from 'app/common/hashingUtils';
|
||||
import {InactivityTimer} from 'app/common/InactivityTimer';
|
||||
import {Interval} from 'app/common/Interval';
|
||||
import * as roles from 'app/common/roles';
|
||||
@@ -1396,9 +1395,9 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
|
||||
const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial';
|
||||
this.logTelemetryEvent(docSession, 'documentForked', {
|
||||
limited: {
|
||||
forkIdDigest: hashId(forkIds.forkId),
|
||||
forkDocIdDigest: hashId(forkIds.docId),
|
||||
trunkIdDigest: doc.trunkId ? hashId(doc.trunkId) : undefined,
|
||||
forkIdDigest: forkIds.forkId,
|
||||
forkDocIdDigest: forkIds.docId,
|
||||
trunkIdDigest: doc.trunkId,
|
||||
isTemplate,
|
||||
lastActivity: doc.updatedAt,
|
||||
},
|
||||
@@ -2540,7 +2539,7 @@ export class ActiveDoc extends EventEmitter implements AssistanceDoc {
|
||||
altSessionId ? {altSessionId} : {},
|
||||
{
|
||||
limited: {
|
||||
docIdDigest: hashId(this._docName),
|
||||
docIdDigest: this._docName,
|
||||
},
|
||||
full: {
|
||||
siteId: this._doc?.workspace.org.id,
|
||||
|
||||
@@ -9,7 +9,6 @@ import fetch, {Response as FetchResponse, RequestInit} from 'node-fetch';
|
||||
import {ApiError} from 'app/common/ApiError';
|
||||
import {getSlugIfNeeded, parseSubdomainStrictly, parseUrlId} from 'app/common/gristUrls';
|
||||
import {removeTrailingSlash} from 'app/common/gutil';
|
||||
import {hashId} from 'app/common/hashingUtils';
|
||||
import {LocalPlugin} from "app/common/plugin";
|
||||
import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry';
|
||||
import {Document as APIDocument} from 'app/common/UserAPI';
|
||||
@@ -304,13 +303,13 @@ export function attachAppEndpoint(options: AttachOptions): void {
|
||||
}
|
||||
|
||||
const isPublic = ((doc as unknown) as APIDocument).public ?? false;
|
||||
const isSnapshot = parseUrlId(urlId).snapshotId;
|
||||
const isSnapshot = Boolean(parseUrlId(urlId).snapshotId);
|
||||
// TODO: Need a more precise way to identify a template. (This org now also has tutorials.)
|
||||
const isTemplate = TEMPLATES_ORG_DOMAIN === doc.workspace.org.domain && doc.type !== 'tutorial';
|
||||
if (isPublic || isTemplate) {
|
||||
gristServer.getTelemetry().logEvent('documentOpened', {
|
||||
limited: {
|
||||
docIdDigest: hashId(docId),
|
||||
docIdDigest: docId,
|
||||
access: doc.access,
|
||||
isPublic,
|
||||
isSnapshot,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import {isRaisedException} from "app/common/gristTypes";
|
||||
import {buildUrlId, parseUrlId} from "app/common/gristUrls";
|
||||
import {isAffirmative} from "app/common/gutil";
|
||||
import {hashId} from "app/common/hashingUtils";
|
||||
import {SchemaTypes} from "app/common/schema";
|
||||
import {SortFunc} from 'app/common/SortFunc';
|
||||
import {Sort} from 'app/common/SortSpec';
|
||||
@@ -918,8 +917,8 @@ export class DocWorkerApi {
|
||||
const {forkId} = parseUrlId(scope.urlId);
|
||||
activeDoc.logTelemetryEvent(docSession, 'tutorialRestarted', {
|
||||
full: {
|
||||
tutorialForkIdDigest: forkId ? hashId(forkId) : undefined,
|
||||
tutorialTrunkIdDigest: hashId(tutorialTrunkId),
|
||||
tutorialForkIdDigest: forkId,
|
||||
tutorialTrunkIdDigest: tutorialTrunkId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes,
|
||||
GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain,
|
||||
sanitizePathTail} from 'app/common/gristUrls';
|
||||
import {getOrgUrlInfo} from 'app/common/gristUrls';
|
||||
import {InstallProperties} from 'app/common/InstallAPI';
|
||||
import {UserProfile} from 'app/common/LoginSessionAPI';
|
||||
import {tbind} from 'app/common/tbind';
|
||||
import * as version from 'app/common/version';
|
||||
@@ -50,14 +51,15 @@ import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/place
|
||||
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
|
||||
import {PluginManager} from 'app/server/lib/PluginManager';
|
||||
import * as ProcessMonitor from 'app/server/lib/ProcessMonitor';
|
||||
import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, optStringParam,
|
||||
RequestWithGristInfo, stringParam, TEST_HTTPS_OFFSET, trustOrigin} from 'app/server/lib/requestUtils';
|
||||
import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, isDefaultUser, optStringParam,
|
||||
RequestWithGristInfo, sendOkReply, stringParam, TEST_HTTPS_OFFSET,
|
||||
trustOrigin} from 'app/server/lib/requestUtils';
|
||||
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
|
||||
import {getDatabaseUrl, listenPromise} from 'app/server/lib/serverUtils';
|
||||
import {Sessions} from 'app/server/lib/Sessions';
|
||||
import * as shutdown from 'app/server/lib/shutdown';
|
||||
import {TagChecker} from 'app/server/lib/TagChecker';
|
||||
import {ITelemetry} from 'app/server/lib/Telemetry';
|
||||
import {getTelemetryPrefs, ITelemetry} from 'app/server/lib/Telemetry';
|
||||
import {startTestingHooks} from 'app/server/lib/TestingHooks';
|
||||
import {getTestLoginSystem} from 'app/server/lib/TestLogin';
|
||||
import {addUploadRoute} from 'app/server/lib/uploads';
|
||||
@@ -706,11 +708,17 @@ export class FlexServer implements GristServer {
|
||||
});
|
||||
}
|
||||
|
||||
public addTelemetry() {
|
||||
public async addTelemetry() {
|
||||
if (this._check('telemetry', 'homedb', 'json', 'api-mw')) { return; }
|
||||
|
||||
this._telemetry = this.create.Telemetry(this._dbManager, this);
|
||||
this._telemetry.addEndpoints(this.app);
|
||||
this._telemetry.addPages(this.app, [
|
||||
this._redirectToHostMiddleware,
|
||||
this._userIdMiddleware,
|
||||
this._redirectToLoginWithoutExceptionsMiddleware,
|
||||
]);
|
||||
await this._telemetry.start();
|
||||
|
||||
// Start up a monitor for memory and cpu usage.
|
||||
this._processMonitorStop = ProcessMonitor.start(this._telemetry);
|
||||
@@ -1198,7 +1206,7 @@ export class FlexServer implements GristServer {
|
||||
];
|
||||
|
||||
this.app.get('/account', ...middleware, expressWrap(async (req, resp) => {
|
||||
return this._sendAppPage(req, resp, {path: 'account.html', status: 200, config: {}});
|
||||
return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1460,6 +1468,43 @@ export class FlexServer implements GristServer {
|
||||
addGoogleAuthEndpoint(this.app, messagePage);
|
||||
}
|
||||
|
||||
public addInstallEndpoints() {
|
||||
if (this._check('install')) { return; }
|
||||
|
||||
const isManager = expressWrap(
|
||||
(req: express.Request, _res: express.Response, next: express.NextFunction) => {
|
||||
if (!isDefaultUser(req)) { throw new ApiError('Access denied', 403); }
|
||||
|
||||
next();
|
||||
}
|
||||
);
|
||||
|
||||
this.app.get('/api/install/prefs', expressWrap(async (_req, resp) => {
|
||||
const activation = await this._activations.current();
|
||||
|
||||
return sendOkReply(null, resp, {
|
||||
telemetry: await getTelemetryPrefs(this._dbManager, activation),
|
||||
});
|
||||
}));
|
||||
|
||||
this.app.patch('/api/install/prefs', isManager, expressWrap(async (req, resp) => {
|
||||
const props = {prefs: req.body};
|
||||
const activation = await this._activations.current();
|
||||
activation.checkProperties(props);
|
||||
activation.updateFromProperties(props);
|
||||
await activation.save();
|
||||
|
||||
if ((props as Partial<InstallProperties>).prefs?.telemetry) {
|
||||
// Make sure the Telemetry singleton picks up the changes to telemetry preferences.
|
||||
// TODO: if there are multiple home server instances, notify them all of changes to
|
||||
// preferences (via Redis Pub/Sub).
|
||||
await this._telemetry.fetchTelemetryPrefs();
|
||||
}
|
||||
|
||||
return resp.status(200).send();
|
||||
}));
|
||||
}
|
||||
|
||||
// Get the HTML template sent for document pages.
|
||||
public async getDocTemplate(): Promise<DocTemplate> {
|
||||
const page = await fse.readFile(path.join(getAppPathTo(this.appRoot, 'static'),
|
||||
|
||||
@@ -138,8 +138,11 @@ export function createDummyGristServer(): GristServer {
|
||||
|
||||
export function createDummyTelemetry(): ITelemetry {
|
||||
return {
|
||||
logEvent() { return Promise.resolve(); },
|
||||
addEndpoints() { /* do nothing */ },
|
||||
getTelemetryLevel() { return 'off'; },
|
||||
addPages() { /* do nothing */ },
|
||||
start() { return Promise.resolve(); },
|
||||
logEvent() { return Promise.resolve(); },
|
||||
getTelemetryConfig() { return undefined; },
|
||||
fetchTelemetryPrefs() { return Promise.resolve(); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {ApiError} from 'app/common/ApiError';
|
||||
import {TelemetryConfig} from 'app/common/gristUrls';
|
||||
import {assertIsDefined} from 'app/common/gutil';
|
||||
import {
|
||||
buildTelemetryEventChecker,
|
||||
filterMetadata,
|
||||
removeNullishKeys,
|
||||
Level,
|
||||
TelemetryContracts,
|
||||
TelemetryEvent,
|
||||
TelemetryEventChecker,
|
||||
TelemetryEvents,
|
||||
@@ -11,18 +13,28 @@ import {
|
||||
TelemetryMetadata,
|
||||
TelemetryMetadataByLevel,
|
||||
} from 'app/common/Telemetry';
|
||||
import {HomeDBManager, HomeDBTelemetryEvents} from 'app/gen-server/lib/HomeDBManager';
|
||||
import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
|
||||
import {Activation} from 'app/gen-server/entity/Activation';
|
||||
import {Activations} from 'app/gen-server/lib/Activations';
|
||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||
import {RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||
import {expressWrap} from 'app/server/lib/expressWrap';
|
||||
import {GristServer} from 'app/server/lib/GristServer';
|
||||
import {hashId} from 'app/server/lib/hashingUtils';
|
||||
import {LogMethods} from 'app/server/lib/LogMethods';
|
||||
import {stringParam} from 'app/server/lib/requestUtils';
|
||||
import * as express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import merge = require('lodash/merge');
|
||||
import pickBy = require('lodash/pickBy');
|
||||
|
||||
export interface ITelemetry {
|
||||
start(): Promise<void>;
|
||||
logEvent(name: TelemetryEvent, metadata?: TelemetryMetadataByLevel): Promise<void>;
|
||||
addEndpoints(app: express.Express): void;
|
||||
getTelemetryLevel(): TelemetryLevel;
|
||||
addPages(app: express.Express, middleware: express.RequestHandler[]): void;
|
||||
getTelemetryConfig(): TelemetryConfig | undefined;
|
||||
fetchTelemetryPrefs(): Promise<void>;
|
||||
}
|
||||
|
||||
const MAX_PENDING_FORWARD_EVENT_REQUESTS = 25;
|
||||
@@ -31,26 +43,30 @@ const MAX_PENDING_FORWARD_EVENT_REQUESTS = 25;
|
||||
* Manages telemetry for Grist.
|
||||
*/
|
||||
export class Telemetry implements ITelemetry {
|
||||
private _telemetryLevel: TelemetryLevel;
|
||||
private _deploymentType = this._gristServer.getDeploymentType();
|
||||
private _shouldForwardTelemetryEvents = this._deploymentType !== 'saas';
|
||||
private _forwardTelemetryEventsUrl = process.env.GRIST_TELEMETRY_URL ||
|
||||
'https://telemetry.getgrist.com/api/telemetry';
|
||||
private _activation: Activation | undefined;
|
||||
private readonly _deploymentType = this._gristServer.getDeploymentType();
|
||||
|
||||
private _telemetryPrefs: TelemetryPrefsWithSources | undefined;
|
||||
|
||||
private readonly _shouldForwardTelemetryEvents = this._deploymentType !== 'saas';
|
||||
private readonly _forwardTelemetryEventsUrl = process.env.GRIST_TELEMETRY_URL ||
|
||||
'https://telemetry.getgrist.com/api/telemetry';
|
||||
private _numPendingForwardEventRequests = 0;
|
||||
|
||||
private _installationId: string | undefined;
|
||||
|
||||
private _logger = new LogMethods('Telemetry ', () => ({}));
|
||||
private _telemetryLogger = new LogMethods('Telemetry ', () => ({
|
||||
private readonly _logger = new LogMethods('Telemetry ', () => ({}));
|
||||
private readonly _telemetryLogger = new LogMethods('Telemetry ', () => ({
|
||||
eventType: 'telemetry',
|
||||
}));
|
||||
|
||||
private _checkEvent: TelemetryEventChecker | undefined;
|
||||
private _checkTelemetryEvent: TelemetryEventChecker | undefined;
|
||||
|
||||
constructor(private _dbManager: HomeDBManager, private _gristServer: GristServer) {
|
||||
this._initialize().catch((e) => {
|
||||
this._logger.error(undefined, 'failed to initialize', e);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public async start() {
|
||||
await this.fetchTelemetryPrefs();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,19 +112,29 @@ export class Telemetry implements ITelemetry {
|
||||
event: TelemetryEvent,
|
||||
metadata?: TelemetryMetadataByLevel
|
||||
) {
|
||||
if (this._telemetryLevel === 'off') { return; }
|
||||
if (!this._checkTelemetryEvent) {
|
||||
this._logger.error(undefined, 'logEvent called but telemetry event checker is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
metadata = filterMetadata(metadata, this._telemetryLevel);
|
||||
const prefs = this._telemetryPrefs;
|
||||
if (!prefs) {
|
||||
this._logger.error(undefined, 'logEvent called but telemetry preferences are undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
const {telemetryLevel} = prefs;
|
||||
if (TelemetryContracts[event] && TelemetryContracts[event].minimumTelemetryLevel > Level[telemetryLevel.value]) {
|
||||
return;
|
||||
}
|
||||
|
||||
metadata = filterMetadata(metadata, telemetryLevel.value);
|
||||
this._checkTelemetryEvent(event, metadata);
|
||||
|
||||
if (this._shouldForwardTelemetryEvents) {
|
||||
await this._forwardEvent(event, metadata);
|
||||
} else {
|
||||
this._telemetryLogger.rawLog('info', null, event, {
|
||||
eventName: event,
|
||||
eventSource: `grist-${this._deploymentType}`,
|
||||
...metadata,
|
||||
});
|
||||
this._logEvent(event, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +151,7 @@ export class Telemetry implements ITelemetry {
|
||||
* source. Otherwise, the event will only be logged after passing various
|
||||
* checks.
|
||||
*/
|
||||
app.post('/api/telemetry', async (req, resp) => {
|
||||
app.post('/api/telemetry', expressWrap(async (req, resp) => {
|
||||
const mreq = req as RequestWithLogin;
|
||||
const event = stringParam(req.body.event, 'event', TelemetryEvents.values);
|
||||
if ('eventSource' in req.body.metadata) {
|
||||
@@ -135,11 +161,12 @@ export class Telemetry implements ITelemetry {
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
this._assertTelemetryIsReady();
|
||||
await this.logEvent(event as TelemetryEvent, merge(
|
||||
{
|
||||
limited: {
|
||||
eventSource: `grist-${this._deploymentType}`,
|
||||
...(this._deploymentType !== 'saas' ? {installationId: this._installationId} : {}),
|
||||
...(this._deploymentType !== 'saas' ? {installationId: this._activation!.id} : {}),
|
||||
},
|
||||
full: {
|
||||
userId: mreq.userId,
|
||||
@@ -154,38 +181,51 @@ export class Telemetry implements ITelemetry {
|
||||
}
|
||||
}
|
||||
return resp.status(200).send();
|
||||
}));
|
||||
}
|
||||
|
||||
public addPages(app: express.Application, middleware: express.RequestHandler[]) {
|
||||
if (this._deploymentType === 'core') {
|
||||
app.get('/support-grist', ...middleware, expressWrap(async (req, resp) => {
|
||||
return this._gristServer.sendAppPage(req, resp,
|
||||
{path: 'app.html', status: 200, config: {}});
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
public getTelemetryConfig(): TelemetryConfig | undefined {
|
||||
const prefs = this._telemetryPrefs;
|
||||
if (!prefs) {
|
||||
this._logger.error(undefined, 'getTelemetryConfig called but telemetry preferences are undefined');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
telemetryLevel: prefs.telemetryLevel.value,
|
||||
};
|
||||
}
|
||||
|
||||
public async fetchTelemetryPrefs() {
|
||||
this._activation = await this._gristServer.getActivations().current();
|
||||
await this._fetchTelemetryPrefs();
|
||||
}
|
||||
|
||||
private async _fetchTelemetryPrefs() {
|
||||
this._telemetryPrefs = await getTelemetryPrefs(this._dbManager, this._activation);
|
||||
this._checkTelemetryEvent = buildTelemetryEventChecker(this._telemetryPrefs.telemetryLevel.value);
|
||||
}
|
||||
|
||||
private _logEvent(
|
||||
event: TelemetryEvent,
|
||||
metadata?: TelemetryMetadata
|
||||
) {
|
||||
this._telemetryLogger.rawLog('info', null, event, {
|
||||
eventName: event,
|
||||
eventSource: `grist-${this._deploymentType}`,
|
||||
...metadata,
|
||||
});
|
||||
}
|
||||
|
||||
public getTelemetryLevel() {
|
||||
return this._telemetryLevel;
|
||||
}
|
||||
|
||||
private async _initialize() {
|
||||
this._telemetryLevel = getTelemetryLevel();
|
||||
if (process.env.GRIST_TELEMETRY_LEVEL !== undefined) {
|
||||
this._checkTelemetryEvent = buildTelemetryEventChecker(this._telemetryLevel);
|
||||
}
|
||||
|
||||
const {id} = await this._gristServer.getActivations().current();
|
||||
this._installationId = id;
|
||||
|
||||
for (const event of HomeDBTelemetryEvents.values) {
|
||||
this._dbManager.on(event, async (metadata) => {
|
||||
this.logEvent(event, metadata).catch(e =>
|
||||
this._logger.error(undefined, `failed to log telemetry event ${event}`, e));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _checkTelemetryEvent(event: TelemetryEvent, metadata?: TelemetryMetadata) {
|
||||
if (!this._checkEvent) {
|
||||
throw new Error('Telemetry._checkEvent is undefined');
|
||||
}
|
||||
|
||||
this._checkEvent(event, metadata);
|
||||
}
|
||||
|
||||
private async _forwardEvent(
|
||||
event: TelemetryEvent,
|
||||
metadata?: TelemetryMetadata
|
||||
@@ -198,7 +238,7 @@ export class Telemetry implements ITelemetry {
|
||||
|
||||
try {
|
||||
this._numPendingForwardEventRequests += 1;
|
||||
await this._postJsonPayload(JSON.stringify({event, metadata}));
|
||||
await this._doForwardEvent(JSON.stringify({event, metadata}));
|
||||
} catch (e) {
|
||||
this._logger.error(undefined, `failed to forward telemetry event ${event}`, e);
|
||||
} finally {
|
||||
@@ -206,7 +246,7 @@ export class Telemetry implements ITelemetry {
|
||||
}
|
||||
}
|
||||
|
||||
private async _postJsonPayload(payload: string) {
|
||||
private async _doForwardEvent(payload: string) {
|
||||
await fetch(this._forwardTelemetryEventsUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -215,12 +255,90 @@ export class Telemetry implements ITelemetry {
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getTelemetryLevel(): TelemetryLevel {
|
||||
if (process.env.GRIST_TELEMETRY_LEVEL !== undefined) {
|
||||
return TelemetryLevels.check(process.env.GRIST_TELEMETRY_LEVEL);
|
||||
} else {
|
||||
return 'off';
|
||||
private _assertTelemetryIsReady() {
|
||||
try {
|
||||
assertIsDefined('activation', this._activation);
|
||||
} catch (e) {
|
||||
this._logger.error(null, 'activation is undefined', e);
|
||||
throw new ApiError('Telemetry is not ready', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTelemetryPrefs(
|
||||
db: HomeDBManager,
|
||||
activation?: Activation
|
||||
): Promise<TelemetryPrefsWithSources> {
|
||||
const GRIST_TELEMETRY_LEVEL = process.env.GRIST_TELEMETRY_LEVEL;
|
||||
if (GRIST_TELEMETRY_LEVEL !== undefined) {
|
||||
const value = TelemetryLevels.check(GRIST_TELEMETRY_LEVEL);
|
||||
return {
|
||||
telemetryLevel: {
|
||||
value,
|
||||
source: 'environment-variable',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const {prefs} = activation ?? await new Activations(db).current();
|
||||
return {
|
||||
telemetryLevel: {
|
||||
value: prefs?.telemetry?.telemetryLevel ?? 'off',
|
||||
source: 'preferences',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new, filtered metadata object, or undefined if `metadata` is undefined.
|
||||
*
|
||||
* Filtering currently:
|
||||
* - removes keys in groups that exceed `telemetryLevel`
|
||||
* - removes keys with values of null or undefined
|
||||
* - hashes the values of keys suffixed with "Digest" (e.g. doc ids, fork ids)
|
||||
* - flattens the entire metadata object (i.e. removes the nesting of keys under
|
||||
* "limited" or "full")
|
||||
*/
|
||||
export function filterMetadata(
|
||||
metadata: TelemetryMetadataByLevel | undefined,
|
||||
telemetryLevel: TelemetryLevel
|
||||
): TelemetryMetadata | undefined {
|
||||
if (!metadata) { return; }
|
||||
|
||||
let filteredMetadata: TelemetryMetadata = {};
|
||||
for (const level of ['limited', 'full'] as const) {
|
||||
if (Level[telemetryLevel] < Level[level]) { break; }
|
||||
|
||||
filteredMetadata = {...filteredMetadata, ...metadata[level]};
|
||||
}
|
||||
|
||||
filteredMetadata = removeNullishKeys(filteredMetadata);
|
||||
filteredMetadata = hashDigestKeys(filteredMetadata);
|
||||
|
||||
return filteredMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of `object` with all null and undefined keys removed.
|
||||
*/
|
||||
export function removeNullishKeys(object: Record<string, any>) {
|
||||
return pickBy(object, value => value !== null && value !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of `metadata`, replacing the values of all keys suffixed
|
||||
* with "Digest" with the result of hashing the value. The hash is prefixed with
|
||||
* the first 4 characters of the original value, to assist with troubleshooting.
|
||||
*/
|
||||
export function hashDigestKeys(metadata: TelemetryMetadata): TelemetryMetadata {
|
||||
const filteredMetadata: TelemetryMetadata = {};
|
||||
Object.entries(metadata).forEach(([key, value]) => {
|
||||
if (key.endsWith('Digest') && typeof value === 'string') {
|
||||
filteredMetadata[key] = hashId(value);
|
||||
} else {
|
||||
filteredMetadata[key] = value;
|
||||
}
|
||||
});
|
||||
return filteredMetadata;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {createHash} from 'crypto';
|
||||
|
||||
/**
|
||||
* Returns a hash of `id` prefixed with the first 4 characters of `id`.
|
||||
* Returns a hash of `id` prefixed with the first 4 characters of `id`. The first 4
|
||||
* characters are included to assist with troubleshooting.
|
||||
*
|
||||
* Useful for situations where potentially sensitive identifiers are logged, such as
|
||||
* doc ids (like those that have public link sharing enabled). The first 4 characters
|
||||
* are included to assist with troubleshooting.
|
||||
* doc ids of docs that have public link sharing enabled.
|
||||
*/
|
||||
export function hashId(id: string): string {
|
||||
return `${id.slice(0, 4)}:${createHash('sha256').update(id.slice(4)).digest('base64')}`;
|
||||
@@ -2,7 +2,7 @@ import {ApiError} from 'app/common/ApiError';
|
||||
import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls';
|
||||
import * as gutil from 'app/common/gutil';
|
||||
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
|
||||
import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||
import {getUser, getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||
import {RequestWithOrg} from 'app/server/lib/extractOrg';
|
||||
import {RequestWithGrist} from 'app/server/lib/GristServer';
|
||||
import log from 'app/server/lib/log';
|
||||
@@ -352,3 +352,9 @@ export function addAbortHandler(req: Request, res: Writable, op: () => void) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function isDefaultUser(req: Request) {
|
||||
const defaultEmail = process.env.GRIST_DEFAULT_EMAIL;
|
||||
const {loginEmail} = getUser(req);
|
||||
return defaultEmail && defaultEmail === loginEmail;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
|
||||
featureFormulaAssistant: isAffirmative(process.env.GRIST_FORMULA_ASSISTANT),
|
||||
supportEmail: SUPPORT_EMAIL,
|
||||
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
|
||||
telemetry: server ? getTelemetryConfig(server) : undefined,
|
||||
telemetry: server?.getTelemetry().getTelemetryConfig(),
|
||||
deploymentType: server?.getDeploymentType(),
|
||||
...extra,
|
||||
};
|
||||
@@ -163,13 +163,6 @@ function getFeatures(): IFeature[] {
|
||||
return Features.checkAll(difference(enabledFeatures, disabledFeatures));
|
||||
}
|
||||
|
||||
function getTelemetryConfig(server: GristServer) {
|
||||
const telemetry = server.getTelemetry();
|
||||
return {
|
||||
telemetryLevel: telemetry.getTelemetryLevel(),
|
||||
};
|
||||
}
|
||||
|
||||
function configuredPageTitleSuffix() {
|
||||
const result = process.env.GRIST_PAGE_TITLE_SUFFIX;
|
||||
return result === "_blank" ? "" : result;
|
||||
|
||||
@@ -119,7 +119,7 @@ export async function main(port: number, serverTypes: ServerType[],
|
||||
server.addHomeApi();
|
||||
server.addBillingApi();
|
||||
server.addNotifier();
|
||||
server.addTelemetry();
|
||||
await server.addTelemetry();
|
||||
await server.addHousekeeper();
|
||||
await server.addLoginRoutes();
|
||||
server.addAccountPage();
|
||||
@@ -127,11 +127,12 @@ export async function main(port: number, serverTypes: ServerType[],
|
||||
server.addWelcomePaths();
|
||||
server.addLogEndpoint();
|
||||
server.addGoogleAuthEndpoint();
|
||||
server.addInstallEndpoints();
|
||||
}
|
||||
|
||||
if (includeDocs) {
|
||||
server.addJsonSupport();
|
||||
server.addTelemetry();
|
||||
await server.addTelemetry();
|
||||
await server.addDoc();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user