(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:
George Gevoian
2023-07-04 17:21:34 -04:00
parent 051c6d52fe
commit 35237a5835
47 changed files with 1743 additions and 365 deletions

10
app/common/Install.ts Normal file
View 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
View 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);
}
}

View File

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

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
import {createHash} from 'crypto';
/**
* Returns a hash of `id` prefixed with the first 4 characters of `id`.
*
* 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.
*/
export function hashId(id: string): string {
return `${id.slice(0, 4)}:${createHash('sha256').update(id.slice(4)).digest('base64')}`;
}