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/D3926pull/563/head
parent
051c6d52fe
commit
35237a5835
@ -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));
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
`);
|
@ -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;
|
||||
`);
|
@ -0,0 +1,10 @@
|
||||
import {TelemetryLevel} from 'app/common/Telemetry';
|
||||
|
||||
export interface InstallPrefs {
|
||||
telemetry?: TelemetryPrefs;
|
||||
}
|
||||
|
||||
export interface TelemetryPrefs {
|
||||
/** Defaults to "off". */
|
||||
telemetryLevel?: TelemetryLevel;
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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');
|
||||
}
|
||||
}
|
@ -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')}`;
|
@ -1,16 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8">
|
||||
<!-- INSERT BASE -->
|
||||
<link rel="icon" type="image/x-icon" href="icons/favicon.png" />
|
||||
<link rel="stylesheet" href="icons/icons.css">
|
||||
<!-- INSERT LOCALE -->
|
||||
<!-- INSERT CONFIG -->
|
||||
<!-- INSERT CUSTOM -->
|
||||
<title>Account<!-- INSERT TITLE SUFFIX --></title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="account.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
@ -1,17 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8">
|
||||
<!-- INSERT BASE -->
|
||||
<link rel="icon" type="image/x-icon" href="icons/favicon.png" />
|
||||
<link rel="stylesheet" href="icons/icons.css">
|
||||
<!-- INSERT LOCALE -->
|
||||
<!-- INSERT CUSTOM -->
|
||||
<title>Activation<!-- INSERT TITLE SUFFIX --></title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- INSERT ERROR -->
|
||||
<!-- INSERT CONFIG -->
|
||||
<script src="activation.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
After Width: | Height: | Size: 1023 B |
@ -0,0 +1,308 @@
|
||||
import {GristLoadConfig} from 'app/common/gristUrls';
|
||||
import {TelemetryLevel} from 'app/common/Telemetry';
|
||||
import {assert, driver} from 'mocha-webdriver';
|
||||
import * as gu from 'test/nbrowser/gristUtils';
|
||||
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
|
||||
import * as testUtils from 'test/server/testUtils';
|
||||
|
||||
describe('SupportGrist', function() {
|
||||
this.timeout(30000);
|
||||
setupTestSuite();
|
||||
|
||||
let oldEnv: testUtils.EnvironmentSnapshot;
|
||||
let session: gu.Session;
|
||||
|
||||
afterEach(() => gu.checkForErrors());
|
||||
|
||||
describe('in grist-core', function() {
|
||||
before(async function() {
|
||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||
process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'core';
|
||||
process.env.GRIST_DEFAULT_EMAIL = gu.session().email;
|
||||
await server.restart();
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
oldEnv.restore();
|
||||
await server.restart();
|
||||
});
|
||||
|
||||
describe('when user is not a manager', function() {
|
||||
before(async function() {
|
||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||
await server.restart();
|
||||
session = await gu.session().user('user2').personalSite.login();
|
||||
await session.loadDocMenu('/');
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
oldEnv.restore();
|
||||
});
|
||||
|
||||
it('does not show a nudge on the doc menu', async function() {
|
||||
await assertNudgeButtonShown(false);
|
||||
await assertNudgeCardShown(false);
|
||||
});
|
||||
|
||||
it('shows a link to the Support Grist page in the user menu', async function() {
|
||||
await gu.openAccountMenu();
|
||||
await driver.find('.test-usermenu-support-grist').click();
|
||||
assert.isTrue(await driver.findContentWait(
|
||||
'.test-support-grist-page-sponsorship-section',
|
||||
/Sponsor Grist Labs on GitHub/,
|
||||
4000
|
||||
).isDisplayed());
|
||||
});
|
||||
|
||||
it('shows a message that telemetry is managed by the site administrator', async function() {
|
||||
assert.isTrue(await driver.findContentWait(
|
||||
'.test-support-grist-page-telemetry-section',
|
||||
/This instance is opted out of telemetry\. Only the site administrator has permission to change this\./,
|
||||
4000
|
||||
).isDisplayed());
|
||||
|
||||
process.env.GRIST_TELEMETRY_LEVEL = 'limited';
|
||||
await server.restart();
|
||||
await driver.navigate().refresh();
|
||||
assert.isTrue(await driver.findContentWait(
|
||||
'.test-support-grist-page-telemetry-section',
|
||||
/This instance is opted in to telemetry\. Only the site administrator has permission to change this\./,
|
||||
4000
|
||||
).isDisplayed());
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user is a manager', function() {
|
||||
before(async function() {
|
||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||
await server.restart();
|
||||
session = await gu.session().personalSite.login();
|
||||
await session.loadDocMenu('/');
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
oldEnv.restore();
|
||||
});
|
||||
|
||||
it('shows a nudge on the doc menu', async function() {
|
||||
// Check that the nudge is expanded by default.
|
||||
await assertNudgeButtonShown(false);
|
||||
await assertNudgeCardShown(true);
|
||||
|
||||
// Reload the doc menu and check that it's still expanded.
|
||||
await session.loadDocMenu('/');
|
||||
await assertNudgeButtonShown(false);
|
||||
await assertNudgeCardShown(true);
|
||||
|
||||
// Close the nudge and check that it's now collapsed.
|
||||
await driver.find('.test-support-grist-nudge-card-close').click();
|
||||
await assertNudgeButtonShown(true);
|
||||
await assertNudgeCardShown(false);
|
||||
|
||||
// Reload again, and check that it's still collapsed.
|
||||
await session.loadDocMenu('/');
|
||||
await assertNudgeButtonShown(true);
|
||||
await assertNudgeCardShown(false);
|
||||
|
||||
// Dismiss the contribute button and check that it's now gone, even after reloading.
|
||||
await driver.find('.test-support-grist-nudge-contribute-button').mouseMove();
|
||||
await driver.find('.test-support-grist-nudge-contribute-button-close').click();
|
||||
await assertNudgeButtonShown(false);
|
||||
await assertNudgeCardShown(false);
|
||||
await session.loadDocMenu('/');
|
||||
await assertNudgeButtonShown(false);
|
||||
await assertNudgeCardShown(false);
|
||||
});
|
||||
|
||||
it('shows a link to the Support Grist page in the user menu', async function() {
|
||||
await gu.openAccountMenu();
|
||||
await driver.find('.test-usermenu-support-grist').click();
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
|
||||
assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent());
|
||||
});
|
||||
|
||||
it('supports opting in to telemetry from the page', async function() {
|
||||
await assertTelemetryLevel('off');
|
||||
await driver.findContentWait(
|
||||
'.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000).click();
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000);
|
||||
assert.equal(
|
||||
await driver.find('.test-support-grist-page-telemetry-section-message').getText(),
|
||||
'You have opted in to telemetry. Thank you! 🙏'
|
||||
);
|
||||
|
||||
// Reload the page and check that the Grist config indicates telemetry is set to "limited".
|
||||
await driver.navigate().refresh();
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000);
|
||||
assert.equal(
|
||||
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
|
||||
'You have opted in to telemetry. Thank you! 🙏'
|
||||
);
|
||||
await assertTelemetryLevel('limited');
|
||||
});
|
||||
|
||||
it('supports opting out of telemetry from the page', async function() {
|
||||
await driver.findContent('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/).click();
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
|
||||
assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent());
|
||||
|
||||
// Reload the page and check that the Grist config indicates telemetry is set to "off".
|
||||
await driver.navigate().refresh();
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
|
||||
assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent());
|
||||
await assertTelemetryLevel('off');
|
||||
});
|
||||
|
||||
it('supports opting in to telemetry from the nudge', async function() {
|
||||
// Reset all dismissed popups, including the telemetry nudge.
|
||||
await driver.executeScript('resetDismissedPopups();');
|
||||
await gu.waitForServer();
|
||||
await session.loadDocMenu('/');
|
||||
|
||||
// Opt in to telemetry and reload the page.
|
||||
await driver.find('.test-support-grist-nudge-card-opt-in').click();
|
||||
await driver.findWait('.test-support-grist-nudge-card-close-button', 1000).click();
|
||||
await assertNudgeButtonShown(false);
|
||||
await assertNudgeCardShown(false);
|
||||
await session.loadDocMenu('/');
|
||||
|
||||
// Check that the nudge is no longer shown and telemetry is set to "limited".
|
||||
await assertNudgeButtonShown(false);
|
||||
await assertNudgeCardShown(false);
|
||||
await assertTelemetryLevel('limited');
|
||||
});
|
||||
|
||||
it('does not show the nudge if telemetry is enabled', async function() {
|
||||
// Reset all dismissed popups, including the telemetry nudge.
|
||||
await driver.executeScript('resetDismissedPopups();');
|
||||
await gu.waitForServer();
|
||||
|
||||
// Reload the doc menu and check that the nudge still isn't shown.
|
||||
await session.loadDocMenu('/');
|
||||
await assertNudgeButtonShown(false);
|
||||
await assertNudgeCardShown(false);
|
||||
|
||||
// Disable telemetry from the Support Grist page.
|
||||
await gu.openAccountMenu();
|
||||
await driver.find('.test-usermenu-support-grist').click();
|
||||
await driver.findContentWait(
|
||||
'.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000).click();
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
|
||||
|
||||
// Reload the doc menu and check that the nudge is now shown.
|
||||
await gu.loadDocMenu('/');
|
||||
await assertNudgeButtonShown(false);
|
||||
await assertNudgeCardShown(true);
|
||||
});
|
||||
|
||||
it('shows telemetry opt-in status even when set via environment variable', async function() {
|
||||
// Set the telemetry level to "limited" via environment variable and restart the server.
|
||||
process.env.GRIST_TELEMETRY_LEVEL = 'limited';
|
||||
await server.restart();
|
||||
|
||||
// Check that the Support Grist page reports telemetry is enabled.
|
||||
await gu.loadDocMenu('/');
|
||||
await gu.openAccountMenu();
|
||||
await driver.find('.test-usermenu-support-grist').click();
|
||||
assert.equal(
|
||||
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
|
||||
'You have opted in to telemetry. Thank you! 🙏'
|
||||
);
|
||||
assert.isFalse(await driver.findContent('.test-support-grist-page-telemetry-section button',
|
||||
/Opt out of Telemetry/).isPresent());
|
||||
|
||||
// Now set the telemetry level to "off" and restart the server.
|
||||
process.env.GRIST_TELEMETRY_LEVEL = 'off';
|
||||
await server.restart();
|
||||
|
||||
// Check that the Support Grist page reports telemetry is disabled.
|
||||
await gu.loadDocMenu('/');
|
||||
await gu.openAccountMenu();
|
||||
await driver.find('.test-usermenu-support-grist').click();
|
||||
assert.equal(
|
||||
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
|
||||
'You have opted out of telemetry.'
|
||||
);
|
||||
assert.isFalse(await driver.findContent('.test-support-grist-page-telemetry-section button',
|
||||
/Opt in to Telemetry/).isPresent());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('in grist-saas', function() {
|
||||
before(async function() {
|
||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||
process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'saas';
|
||||
process.env.GRIST_DEFAULT_EMAIL = gu.session().email;
|
||||
await server.restart();
|
||||
session = await gu.session().personalSite.login();
|
||||
await session.loadDocMenu('/');
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
oldEnv.restore();
|
||||
await server.restart();
|
||||
});
|
||||
|
||||
it('does not show a nudge on the doc menu', async function() {
|
||||
await assertNudgeButtonShown(false);
|
||||
await assertNudgeCardShown(false);
|
||||
});
|
||||
|
||||
it('does not show a link to the Support Grist page in the user menu', async function() {
|
||||
await gu.openAccountMenu();
|
||||
assert.isFalse(await driver.find('.test-usermenu-support-grist').isPresent());
|
||||
});
|
||||
});
|
||||
|
||||
describe('in grist-enterprise', function() {
|
||||
before(async function() {
|
||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||
process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'enterprise';
|
||||
process.env.GRIST_DEFAULT_EMAIL = gu.session().email;
|
||||
await server.restart();
|
||||
session = await gu.session().personalSite.login();
|
||||
await session.loadDocMenu('/');
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
oldEnv.restore();
|
||||
await server.restart();
|
||||
});
|
||||
|
||||
it('does not show a nudge on the doc menu', async function() {
|
||||
await assertNudgeButtonShown(false);
|
||||
await assertNudgeCardShown(false);
|
||||
});
|
||||
|
||||
it('does not show a link to the Support Grist page in the user menu', async function() {
|
||||
await gu.openAccountMenu();
|
||||
assert.isFalse(await driver.find('.test-usermenu-support-grist').isPresent());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function assertNudgeButtonShown(isShown: boolean) {
|
||||
if (isShown) {
|
||||
assert.isTrue(
|
||||
await driver.find('.test-support-grist-nudge-contribute-button').isDisplayed()
|
||||
);
|
||||
} else {
|
||||
assert.isFalse(await driver.find('.test-support-grist-nudge-contribute-button').isPresent());
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNudgeCardShown(isShown: boolean) {
|
||||
if (isShown) {
|
||||
assert.isTrue(
|
||||
await driver.find('.test-support-grist-nudge-card').isDisplayed()
|
||||
);
|
||||
} else {
|
||||
assert.isFalse(await driver.find('.test-support-grist-nudge-card').isPresent());
|
||||
}
|
||||
}
|
||||
|
||||
async function assertTelemetryLevel(level: TelemetryLevel) {
|
||||
const {telemetry}: GristLoadConfig = await driver.executeScript('return window.gristConfig');
|
||||
assert.equal(telemetry?.telemetryLevel, level);
|
||||
}
|
Loading…
Reference in new issue