(core) Update onboarding flow

Summary:
A new onboarding page is now shown to all new users visiting the doc
menu for the first time. Tutorial cards on the doc menu have been
replaced with a new version that tracks completion progress, alongside
a new card that opens the orientation video.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4296
This commit is contained in:
George Gevoian
2024-07-22 11:10:57 -04:00
parent 3fd8719d8a
commit 4740f1f933
40 changed files with 1462 additions and 706 deletions

View File

@@ -25,7 +25,6 @@ export type CommandName =
| 'expandSection'
| 'leftPanelOpen'
| 'rightPanelOpen'
| 'videoTourToolsOpen'
| 'cursorDown'
| 'cursorUp'
| 'cursorRight'
@@ -269,11 +268,6 @@ export const groups: CommendGroupDef[] = [{
keys: [],
desc: 'Shortcut to open the right panel',
},
{
name: 'videoTourToolsOpen',
keys: [],
desc: 'Shortcut to open video tour from home left panel',
},
{
name: 'activateAssistant',
keys: [],

View File

@@ -392,6 +392,10 @@ export class AppModelImpl extends Disposable implements AppModel {
this.behavioralPromptsManager.reset();
};
G.window.resetOnboarding = () => {
getUserPrefObs(this.userPrefsObs, 'showNewUserQuestions').set(true);
};
this.autoDispose(subscribe(urlState().state, this.topAppModel.orgs, async (_use, s, orgs) => {
this._updateLastVisitedOrgDomain(s, orgs);
}));

View File

@@ -7,7 +7,7 @@ import {AppModel, reportError} from 'app/client/models/AppModel';
import {reportMessage, UserError} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState';
import {ownerName} from 'app/client/models/WorkspaceInfo';
import {IHomePage} from 'app/common/gristUrls';
import {IHomePage, isFeatureEnabled} from 'app/common/gristUrls';
import {isLongerThan} from 'app/common/gutil';
import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs';
import * as roles from 'app/common/roles';
@@ -59,6 +59,8 @@ export interface HomeModel {
shouldShowAddNewTip: Observable<boolean>;
onboardingTutorial: Observable<Document|null>;
createWorkspace(name: string): Promise<void>;
renameWorkspace(id: number, name: string): Promise<void>;
deleteWorkspace(id: number, forever: boolean): Promise<void>;
@@ -141,6 +143,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
public readonly shouldShowAddNewTip = Observable.create(this,
!this._app.behavioralPromptsManager.hasSeenPopup('addNew'));
public readonly onboardingTutorial = Observable.create<Document|null>(this, null);
private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(this, this._app.currentOrg?.userOrgPrefs);
constructor(private _app: AppModel, clientScope: ClientScope) {
@@ -176,6 +180,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
this.importSources.set(importSources);
this._app.refreshOrgUsage().catch(reportError);
this._loadWelcomeTutorial().catch(reportError);
}
// Accessor for the AppModel containing this HomeModel.
@@ -370,6 +376,28 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
return templateWss;
}
private async _loadWelcomeTutorial() {
const {templateOrg, onboardingTutorialDocId} = getGristConfig();
if (
!isFeatureEnabled('tutorials') ||
!templateOrg ||
!onboardingTutorialDocId ||
this._app.dismissedPopups.get().includes('onboardingCards')
) {
return;
}
try {
const doc = await this._app.api.getTemplate(onboardingTutorialDocId);
if (this.isDisposed()) { return; }
this.onboardingTutorial.set(doc);
} catch (e) {
console.error(e);
reportError('Failed to load welcome tutorial');
}
}
private async _saveUserOrgPref<K extends keyof UserOrgPrefs>(key: K, value: UserOrgPrefs[K]) {
const org = this._app.currentOrg;
if (org) {

View File

@@ -1,13 +1,7 @@
import {HomeModel} from 'app/client/models/HomeModel';
import {shouldShowWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
export function attachAddNewTip(home: HomeModel): (el: Element) => void {
return () => {
const {app: {userPrefsObs}} = home;
if (shouldShowWelcomeQuestions(userPrefsObs)) {
return;
}
if (shouldShowAddNewTip(home)) {
showAddNewTip(home);
}

View File

@@ -13,6 +13,7 @@ import {createDocMenu} from 'app/client/ui/DocMenu';
import {createForbiddenPage, createNotFoundPage, createOtherErrorPage} from 'app/client/ui/errorPages';
import {createHomeLeftPane} from 'app/client/ui/HomeLeftPane';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {OnboardingPage, shouldShowOnboardingPage} from 'app/client/ui/OnboardingPage';
import {pagePanels} from 'app/client/ui/PagePanels';
import {RightPanel} from 'app/client/ui/RightPanel';
import {createTopBarDoc, createTopBarHome} from 'app/client/ui/TopBar';
@@ -90,6 +91,10 @@ function createMainPage(appModel: AppModel, appObj: App) {
}
function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) {
if (shouldShowOnboardingPage(appModel.userPrefsObs)) {
return dom.create(OnboardingPage, appModel);
}
const pageModel = HomeModelImpl.create(owner, appModel, app.clientScope);
const leftPanelOpen = Observable.create(owner, true);

View File

@@ -13,16 +13,15 @@ import {attachAddNewTip} from 'app/client/ui/AddNewTip';
import * as css from 'app/client/ui/DocMenuCss';
import {buildHomeIntro, buildWorkspaceIntro} from 'app/client/ui/HomeIntro';
import {buildUpgradeButton} from 'app/client/ui/ProductUpgrades';
import {buildTutorialCard} from 'app/client/ui/TutorialCard';
import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {makeShareDocUrl} from 'app/client/ui/ShareMenu';
import {transition} from 'app/client/ui/transitions';
import {shouldShowWelcomeCoachingCall, showWelcomeCoachingCall} from 'app/client/ui/WelcomeCoachingCall';
import {shouldShowWelcomeQuestions, showWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
import {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour';
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
import {isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars';
import {buildOnboardingCards} from 'app/client/ui/OnboardingCards';
import {icon} from 'app/client/ui2018/icons';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
@@ -62,10 +61,8 @@ export function createDocMenu(home: HomeModel): DomElementArg[] {
function attachWelcomePopups(home: HomeModel): (el: Element) => void {
return (element: Element) => {
const {app, app: {userPrefsObs}} = home;
if (shouldShowWelcomeQuestions(userPrefsObs)) {
showWelcomeQuestions(userPrefsObs);
} else if (shouldShowWelcomeCoachingCall(app)) {
const {app} = home;
if (shouldShowWelcomeCoachingCall(app)) {
showWelcomeCoachingCall(element, app);
}
};
@@ -75,116 +72,117 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
const flashDocId = observable<string|null>(null);
const upgradeButton = buildUpgradeButton(owner, home.app);
return css.docList( /* vbox */
/* first line */
dom.create(buildTutorialCard, { app: home.app }),
/* hbox */
css.docListContent(
/* left column - grow 1 */
css.docMenu(
attachAddNewTip(home),
/* first line */
dom.create(buildOnboardingCards, {homeModel: home}),
/* hbox */
css.docListContent(
/* left column - grow 1 */
css.docMenu(
attachAddNewTip(home),
dom.maybe(!home.app.currentFeatures?.workspaces, () => [
css.docListHeader(t("This service is not available right now")),
dom('span', t("(The organization needs a paid plan)")),
]),
dom.maybe(!home.app.currentFeatures?.workspaces, () => [
css.docListHeader(t("This service is not available right now")),
dom('span', t("(The organization needs a paid plan)")),
]),
// currentWS and showIntro observables change together. We capture both in one domComputed call.
dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
(use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
([page, workspace, showIntro]) => {
const viewSettings: ViewSettings =
page === 'trash' ? makeLocalViewSettings(home, 'trash') :
page === 'templates' ? makeLocalViewSettings(home, 'templates') :
workspace ? makeLocalViewSettings(home, workspace.id) :
home;
return [
buildPrefs(
viewSettings,
// Hide the sort and view options when showing the intro.
{hideSort: showIntro, hideView: showIntro && page === 'all'},
['all', 'workspace'].includes(page)
? upgradeButton.showUpgradeButton(css.upgradeButton.cls(''))
: null,
),
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded.
// TODO: this is shown on all pages, but there is a hack in currentWSPinnedDocs that
// removes all pinned docs when on trash page.
dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [
css.docListHeader(css.pinnedDocsIcon('PinBig'), t("Pinned Documents")),
createPinnedDocs(home, home.currentWSPinnedDocs),
]),
// Build the featured templates dom if on the Examples & Templates page.
dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [
css.featuredTemplatesHeader(
css.featuredTemplatesIcon('Idea'),
t("Featured"),
testId('featured-templates-header')
// currentWS and showIntro observables change together. We capture both in one domComputed call.
dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
(use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
([page, workspace, showIntro]) => {
const viewSettings: ViewSettings =
page === 'trash' ? makeLocalViewSettings(home, 'trash') :
page === 'templates' ? makeLocalViewSettings(home, 'templates') :
workspace ? makeLocalViewSettings(home, workspace.id) :
home;
return [
buildPrefs(
viewSettings,
// Hide the sort and view options when showing the intro.
{hideSort: showIntro, hideView: showIntro && page === 'all'},
['all', 'workspace'].includes(page)
? upgradeButton.showUpgradeButton(css.upgradeButton.cls(''))
: null,
),
createPinnedDocs(home, home.featuredTemplates, true),
]),
dom.maybe(home.available, () => [
buildOtherSites(home),
(showIntro && page === 'all' ?
null :
css.docListHeader(
(
page === 'all' ? t("All Documents") :
page === 'templates' ?
dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
hasFeaturedTemplates ? t("More Examples and Templates") : t("Examples and Templates")
) :
page === 'trash' ? t("Trash") :
workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
),
testId('doc-header'),
)
),
(
(page === 'all') ?
dom('div',
showIntro ? buildHomeIntro(home) : null,
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
) :
(page === 'trash') ?
dom('div',
css.docBlock(t("Documents stay in Trash for 30 days, after which they get deleted permanently.")),
dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
css.docBlock(t("Trash is empty."))
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded.
// TODO: this is shown on all pages, but there is a hack in currentWSPinnedDocs that
// removes all pinned docs when on trash page.
dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [
css.docListHeader(css.pinnedDocsIcon('PinBig'), t("Pinned Documents")),
createPinnedDocs(home, home.currentWSPinnedDocs),
]),
// Build the featured templates dom if on the Examples & Templates page.
dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [
css.featuredTemplatesHeader(
css.featuredTemplatesIcon('Idea'),
t("Featured"),
testId('featured-templates-header')
),
createPinnedDocs(home, home.featuredTemplates, true),
]),
dom.maybe(home.available, () => [
buildOtherSites(home),
(showIntro && page === 'all' ?
null :
css.docListHeader(
(
page === 'all' ? t("All Documents") :
page === 'templates' ?
dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
hasFeaturedTemplates ? t("More Examples and Templates") : t("Examples and Templates")
) :
page === 'trash' ? t("Trash") :
workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
),
buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
) :
(page === 'templates') ?
dom('div',
buildAllTemplates(home, home.templateWorkspaces, viewSettings)
) :
workspace && !workspace.isSupportWorkspace && workspace.docs?.length ?
css.docBlock(
buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings),
testId('doc-block')
testId('doc-header'),
)
),
(
(page === 'all') ?
dom('div',
showIntro ? buildHomeIntro(home) : null,
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
) :
workspace && !workspace.isSupportWorkspace && workspace.docs?.length === 0 ?
buildWorkspaceIntro(home) :
css.docBlock(t("Workspace not found"))
)
]),
(page === 'trash') ?
dom('div',
css.docBlock(t("Documents stay in Trash for 30 days, after which they get deleted permanently.")),
dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
css.docBlock(t("Trash is empty."))
),
buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
) :
(page === 'templates') ?
dom('div',
buildAllTemplates(home, home.templateWorkspaces, viewSettings)
) :
workspace && !workspace.isSupportWorkspace && workspace.docs?.length ?
css.docBlock(
buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings),
testId('doc-block')
) :
workspace && !workspace.isSupportWorkspace && workspace.docs?.length === 0 ?
buildWorkspaceIntro(home) :
css.docBlock(t("Workspace not found"))
)
]),
];
}),
testId('doclist')
),
dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
() => {
// 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.buildNudgeCard(),
];
}),
testId('doclist')
),
dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
() => {
// 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.buildNudgeCard(),
];
}),
));
);
}
function buildAllDocsBlock(

View File

@@ -1,11 +1,12 @@
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
import {renderer} from 'app/client/ui/DocTutorialRenderer';
import {cssPopupBody, FLOATING_POPUP_TOOLTIP_KEY, FloatingPopup} from 'app/client/ui/FloatingPopup';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {hoverTooltip, setHoverTooltip} from 'app/client/ui/tooltips';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
import {mediaXSmall, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {loadingSpinner} from 'app/client/ui2018/loaders';
@@ -24,6 +25,8 @@ interface DocTutorialSlide {
imageUrls: string[];
}
const t = makeT('DocTutorial');
const testId = makeTestId('test-doc-tutorial-');
export class DocTutorial extends FloatingPopup {
@@ -35,12 +38,12 @@ export class DocTutorial extends FloatingPopup {
private _docId = this._gristDoc.docId();
private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);
private _currentSlideIndex = Observable.create(this, this._currentFork?.options?.tutorial?.lastSlideIndex ?? 0);
private _percentComplete = this._currentFork?.options?.tutorial?.percentComplete;
private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, {
// Save new position immediately if at least 1 second has passed since the last change.
private _saveProgressDebounced = debounce(this._saveProgress, 1000, {
// Save progress immediately if at least 1 second has passed since the last change.
leading: true,
// Otherwise, wait for the new position to settle for 1 second before saving it.
// Otherwise, wait 1 second before saving.
trailing: true
});
@@ -49,6 +52,18 @@ export class DocTutorial extends FloatingPopup {
minimizable: true,
stopClickPropagationOnMove: true,
});
this.autoDispose(this._currentSlideIndex.addListener((slideIndex) => {
const numSlides = this._slides.get()?.length ?? 0;
if (numSlides > 0) {
this._percentComplete = Math.max(
Math.floor((slideIndex / numSlides) * 100),
this._percentComplete ?? 0
);
} else {
this._percentComplete = undefined;
}
}));
}
public async start() {
@@ -103,13 +118,6 @@ export class DocTutorial extends FloatingPopup {
const isFirstSlide = slideIndex === 0;
const isLastSlide = slideIndex === numSlides - 1;
return [
cssFooterButtonsLeft(
cssPopupFooterButton(icon('Undo'),
hoverTooltip('Restart Tutorial', {key: FLOATING_POPUP_TOOLTIP_KEY}),
dom.on('click', () => this._restartTutorial()),
testId('popup-restart'),
),
),
cssProgressBar(
range(slides.length).map((i) => cssProgressBarDot(
hoverTooltip(slides[i].slideTitle, {
@@ -121,17 +129,17 @@ export class DocTutorial extends FloatingPopup {
testId(`popup-slide-${i + 1}`),
)),
),
cssFooterButtonsRight(
basicButton('Previous',
cssFooterButtons(
basicButton(t('Previous'),
dom.on('click', async () => {
await this._previousSlide();
}),
{style: `visibility: ${isFirstSlide ? 'hidden' : 'visible'}`},
testId('popup-previous'),
),
primaryButton(isLastSlide ? 'Finish': 'Next',
primaryButton(isLastSlide ? t('Finish'): t('Next'),
isLastSlide
? dom.on('click', async () => await this._finishTutorial())
? dom.on('click', async () => await this._exitTutorial(true))
: dom.on('click', async () => await this._nextSlide()),
testId('popup-next'),
),
@@ -140,6 +148,21 @@ export class DocTutorial extends FloatingPopup {
}),
testId('popup-footer'),
),
cssTutorialControls(
cssTextButton(
cssRestartIcon('Undo'),
t('Restart'),
dom.on('click', () => this._restartTutorial()),
testId('popup-restart'),
),
cssButtonsSeparator(),
cssTextButton(
cssSkipIcon('Skip'),
t('End tutorial'),
dom.on('click', () => this._exitTutorial()),
testId('popup-end-tutorial'),
),
),
];
}
@@ -161,19 +184,13 @@ export class DocTutorial extends FloatingPopup {
}
private _logTelemetryEvent(event: 'tutorialOpened' | 'tutorialProgressChanged') {
const currentSlideIndex = this._currentSlideIndex.get();
const numSlides = this._slides.get()?.length;
let percentComplete: number | undefined = undefined;
if (numSlides !== undefined && numSlides > 0) {
percentComplete = Math.floor(((currentSlideIndex + 1) / numSlides) * 100);
}
logTelemetryEvent(event, {
full: {
tutorialForkIdDigest: this._currentFork?.id,
tutorialTrunkIdDigest: this._currentFork?.trunkId,
lastSlideIndex: currentSlideIndex,
numSlides,
percentComplete,
lastSlideIndex: this._currentSlideIndex.get(),
numSlides: this._slides.get()?.length,
percentComplete: this._percentComplete,
},
});
}
@@ -251,14 +268,13 @@ export class DocTutorial extends FloatingPopup {
}
}
private async _saveCurrentSlidePosition() {
const currentOptions = this._currentDoc?.options ?? {};
const currentSlideIndex = this._currentSlideIndex.get();
private async _saveProgress() {
await this._appModel.api.updateDoc(this._docId, {
options: {
...currentOptions,
...this._currentFork?.options,
tutorial: {
lastSlideIndex: currentSlideIndex,
lastSlideIndex: this._currentSlideIndex.get(),
percentComplete: this._percentComplete,
}
}
});
@@ -267,7 +283,7 @@ export class DocTutorial extends FloatingPopup {
private async _changeSlide(slideIndex: number) {
this._currentSlideIndex.set(slideIndex);
await this._saveCurrentSlidePositionDebounced();
await this._saveProgressDebounced();
}
private async _previousSlide() {
@@ -278,9 +294,10 @@ export class DocTutorial extends FloatingPopup {
await this._changeSlide(this._currentSlideIndex.get() + 1);
}
private async _finishTutorial() {
this._saveCurrentSlidePositionDebounced.cancel();
await this._saveCurrentSlidePosition();
private async _exitTutorial(markAsComplete = false) {
this._saveProgressDebounced.cancel();
if (markAsComplete) { this._percentComplete = 100; }
await this._saveProgressDebounced();
const lastVisitedOrg = this._appModel.lastVisitedOrgDomain.get();
if (lastVisitedOrg) {
await urlState().pushUrl({org: lastVisitedOrg});
@@ -298,8 +315,8 @@ export class DocTutorial extends FloatingPopup {
};
confirmModal(
'Do you want to restart the tutorial? All progress will be lost.',
'Restart',
t('Do you want to restart the tutorial? All progress will be lost.'),
t('Restart'),
doRestart,
{
modalOptions: {
@@ -321,7 +338,7 @@ export class DocTutorial extends FloatingPopup {
// eslint-disable-next-line no-self-assign
img.src = img.src;
setHoverTooltip(img, 'Click to expand', {
setHoverTooltip(img, t('Click to expand'), {
key: FLOATING_POPUP_TOOLTIP_KEY,
modifiers: {
flip: {
@@ -357,14 +374,13 @@ export class DocTutorial extends FloatingPopup {
}
}
const cssPopupFooter = styled('div', `
display: flex;
column-gap: 24px;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: 24px 16px 24px 16px;
padding: 16px;
border-top: 1px solid ${theme.tutorialsPopupBorder};
`);
@@ -375,19 +391,6 @@ const cssTryItOutBox = styled('div', `
background-color: ${theme.tutorialsPopupBoxBg};
`);
const cssPopupFooterButton = styled('div', `
--icon-color: ${theme.controlSecondaryFg};
padding: 4px;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: ${theme.hover};
}
`);
const cssProgressBar = styled('div', `
display: flex;
gap: 8px;
@@ -409,11 +412,7 @@ const cssProgressBarDot = styled('div', `
}
`);
const cssFooterButtonsLeft = styled('div', `
flex-shrink: 0;
`);
const cssFooterButtonsRight = styled('div', `
const cssFooterButtons = styled('div', `
display: flex;
justify-content: flex-end;
column-gap: 8px;
@@ -473,3 +472,34 @@ const cssSpinner = styled('div', `
align-items: center;
height: 100%;
`);
const cssTutorialControls = styled('div', `
background-color: ${theme.notificationsPanelHeaderBg};
display: flex;
justify-content: center;
padding: 8px;
`);
const cssTextButton = styled(textButton, `
font-weight: 500;
display: flex;
align-items: center;
column-gap: 4px;
padding: 0 16px;
`);
const cssRestartIcon = styled(icon, `
width: 14px;
height: 14px;
`);
const cssButtonsSeparator = styled('div', `
width: 0;
border-right: 1px solid ${theme.controlFg};
`);
const cssSkipIcon = styled(icon, `
width: 20px;
height: 20px;
margin: 0px -3px;
`);

View File

@@ -31,7 +31,8 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
const creating = observable<boolean>(false);
const renaming = observable<Workspace|null>(null);
const isAnonymous = !home.app.currentValidUser;
const canCreate = !isAnonymous || getGristConfig().enableAnonPlayground;
const {enableAnonPlayground, templateOrg, onboardingTutorialDocId} = getGristConfig();
const canCreate = !isAnonymous || enableAnonPlayground;
return cssContent(
dom.autoDispose(creating),
@@ -114,7 +115,7 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
)),
cssTools(
cssPageEntry(
dom.show(isFeatureEnabled("templates") && Boolean(getGristConfig().templateOrg)),
dom.show(isFeatureEnabled("templates") && Boolean(templateOrg)),
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "templates"),
cssPageLink(cssPageIcon('Board'), cssLinkText(t("Examples & Templates")),
urlState().setLinkUrl({homePage: "templates"}),
@@ -130,9 +131,9 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
),
cssSpacer(),
cssPageEntry(
dom.show(isFeatureEnabled('tutorials')),
dom.show(isFeatureEnabled('tutorials') && Boolean(templateOrg && onboardingTutorialDocId)),
cssPageLink(cssPageIcon('Bookmark'), cssLinkText(t("Tutorial")),
{ href: commonUrls.basicTutorial, target: '_blank' },
urlState().setLinkUrl({org: templateOrg!, doc: onboardingTutorialDocId}),
testId('dm-basic-tutorial'),
),
),

View File

@@ -0,0 +1,232 @@
import {makeT} from 'app/client/lib/localization';
import {urlState} from 'app/client/models/gristUrlState';
import {HomeModel} from 'app/client/models/HomeModel';
import {openVideoTour} from 'app/client/ui/OpenVideoTour';
import {bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
import {colors, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {isFeatureEnabled} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, dom, IDisposableOwner, makeTestId, styled, subscribeElem} from 'grainjs';
interface BuildOnboardingCardsOptions {
homeModel: HomeModel;
}
const t = makeT('OnboardingCards');
const testId = makeTestId('test-onboarding-');
export function buildOnboardingCards(
owner: IDisposableOwner,
{homeModel}: BuildOnboardingCardsOptions
) {
const {templateOrg, onboardingTutorialDocId} = getGristConfig();
if (!isFeatureEnabled('tutorials') || !templateOrg || !onboardingTutorialDocId) { return null; }
const percentComplete = Computed.create(owner, (use) => {
if (!homeModel.app.currentValidUser) { return 0; }
const tutorial = use(homeModel.onboardingTutorial);
if (!tutorial) { return undefined; }
return tutorial.forks?.[0]?.options?.tutorial?.percentComplete ?? 0;
});
const shouldShowCards = Computed.create(owner, (use) =>
!use(homeModel.app.dismissedPopups).includes('onboardingCards'));
let videoPlayButtonElement: HTMLElement;
return dom.maybe(shouldShowCards, () =>
cssOnboardingCards(
cssTutorialCard(
cssDismissCardsButton(
icon('CrossBig'),
dom.on('click', () => homeModel.app.dismissPopup('onboardingCards', true)),
testId('dismiss-cards'),
),
cssTutorialCardHeader(
t('Complete our basics tutorial'),
),
cssTutorialCardSubHeader(
t('Learn the basic of reference columns, linked widgets, column types, & cards.')
),
cssTutorialCardBody(
cssTutorialProgress(
cssTutorialProgressText(
cssProgressPercentage(
dom.domComputed(percentComplete, (percent) => percent !== undefined ? `${percent}%` : null),
testId('tutorial-percent-complete'),
),
cssStarIcon('Star'),
),
cssTutorialProgressBar(
(elem) => subscribeElem(elem, percentComplete, (val) => {
elem.style.setProperty('--percent-complete', String(val ?? 0));
})
),
),
bigPrimaryButtonLink(
t('Complete the tutorial'),
urlState().setLinkUrl({org: templateOrg, doc: onboardingTutorialDocId}),
),
),
testId('tutorial-card'),
),
cssVideoCard(
cssVideoThumbnail(
cssVideoThumbnailSpacer(),
videoPlayButtonElement = cssVideoPlayButton(
cssPlayIcon('VideoPlay2'),
),
cssVideoThumbnailText(t('3 minute video tour')),
),
dom.on('click', () => openVideoTour(videoPlayButtonElement)),
),
)
);
}
const cssOnboardingCards = styled('div', `
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, max-content));
gap: 24px;
margin: 24px 0;
`);
const cssTutorialCard = styled('div', `
position: relative;
border-radius: 4px;
color: ${theme.announcementPopupFg};
background-color: ${theme.announcementPopupBg};
padding: 16px 24px;
`);
const cssTutorialCardHeader = styled('div', `
display: flex;
align-items: flex-start;
justify-content: space-between;
font-size: 18px;
font-style: normal;
font-weight: 700;
`);
const cssDismissCardsButton = styled('div', `
position: absolute;
top: 8px;
right: 8px;
padding: 4px;
border-radius: 4px;
cursor: pointer;
--icon-color: ${theme.popupCloseButtonFg};
&:hover {
background-color: ${theme.hover};
}
`);
const cssTutorialCardSubHeader = styled('div', `
font-size: 14px;
font-style: normal;
font-weight: 500;
margin: 8px 0;
`);
const cssTutorialCardBody = styled('div', `
display: flex;
flex-wrap: wrap;
gap: 24px;
margin: 16px 0;
align-items: end;
`);
const cssTutorialProgress = styled('div', `
flex: auto;
min-width: 120px;
`);
const cssTutorialProgressText = styled('div', `
display: flex;
justify-content: space-between;
`);
const cssProgressPercentage = styled('div', `
font-size: 20px;
font-style: normal;
font-weight: 700;
`);
const cssStarIcon = styled(icon, `
--icon-color: ${theme.accentIcon};
width: 24px;
height: 24px;
`);
const cssTutorialProgressBar = styled('div', `
margin-top: 4px;
height: 10px;
border-radius: 8px;
background: ${theme.mainPanelBg};
--percent-complete: 0;
&::after {
content: '';
border-radius: 8px;
background: ${theme.progressBarFg};
display: block;
height: 100%;
width: calc((var(--percent-complete) / 100) * 100%);
}
`);
const cssVideoCard = styled('div', `
width: 220px;
height: 158px;
overflow: hidden;
cursor: pointer;
border-radius: 4px;
`);
const cssVideoThumbnail = styled('div', `
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 36px 32px;
background-image: url("img/youtube-screenshot.png");
background-color: rgba(0, 0, 0, 0.4);
background-blend-mode: multiply;
background-size: cover;
transform: scale(1.2);
width: 100%;
height: 100%;
`);
const cssVideoThumbnailSpacer = styled('div', ``);
const cssVideoPlayButton = styled('div', `
display: flex;
justify-content: center;
align-items: center;
align-self: center;
width: 32px;
height: 32px;
background-color: ${theme.controlPrimaryBg};
border-radius: 50%;
.${cssVideoThumbnail.className}:hover & {
background-color: ${theme.controlPrimaryHoverBg};
}
`);
const cssPlayIcon = styled(icon, `
--icon-color: ${theme.controlPrimaryFg};
width: 24px;
height: 24px;
`);
const cssVideoThumbnailText = styled('div', `
color: ${colors.light};
font-weight: 700;
text-align: center;
`);

View File

@@ -0,0 +1,747 @@
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {makeT} from 'app/client/lib/localization';
import {AppModel} from 'app/client/models/AppModel';
import {logError} from 'app/client/models/errors';
import {getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
import {getUserPrefObs} from 'app/client/models/UserPrefs';
import {textInput} from 'app/client/ui/inputs';
import {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer';
import {bigBasicButton, bigPrimaryButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
import {colors, mediaMedium, mediaXSmall, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {IconName} from 'app/client/ui2018/IconList';
import {modal} from 'app/client/ui2018/modals';
import {BaseAPI} from 'app/common/BaseAPI';
import {getPageTitleSuffix, ONBOARDING_VIDEO_YOUTUBE_EMBED_ID} from 'app/common/gristUrls';
import {UserPrefs} from 'app/common/Prefs';
import {getGristConfig} from 'app/common/urlUtils';
import {
Computed,
Disposable,
dom,
DomContents,
IDisposableOwner,
input,
makeTestId,
Observable,
styled,
subscribeElem,
} from 'grainjs';
const t = makeT('OnboardingPage');
const testId = makeTestId('test-onboarding-');
const choices: Array<{icon: IconName, color: string, textKey: string}> = [
{icon: 'UseProduct', color: `${colors.lightGreen}`, textKey: 'Product Development' },
{icon: 'UseFinance', color: '#0075A2', textKey: 'Finance & Accounting'},
{icon: 'UseMedia', color: '#F7B32B', textKey: 'Media Production' },
{icon: 'UseMonitor', color: '#F2545B', textKey: 'IT & Technology' },
{icon: 'UseChart', color: '#7141F9', textKey: 'Marketing' },
{icon: 'UseScience', color: '#231942', textKey: 'Research' },
{icon: 'UseSales', color: '#885A5A', textKey: 'Sales' },
{icon: 'UseEducate', color: '#4A5899', textKey: 'Education' },
{icon: 'UseHr', color: '#688047', textKey: 'HR & Management' },
{icon: 'UseOther', color: '#929299', textKey: 'Other' },
];
export function shouldShowOnboardingPage(userPrefsObs: Observable<UserPrefs>): boolean {
return Boolean(getGristConfig().survey && userPrefsObs.get()?.showNewUserQuestions);
}
type IncrementStep = (delta?: 1 | -1) => void;
interface Step {
state?: QuestionsState | VideoState;
buildDom(): DomContents;
onNavigateAway?(): void;
}
interface QuestionsState {
organization: Observable<string>;
role: Observable<string>;
useCases: Array<Observable<boolean>>;
useOther: Observable<string>;
}
interface VideoState {
watched: Observable<boolean>;
}
export class OnboardingPage extends Disposable {
private _steps: Array<Step>;
private _stepIndex: Observable<number> = Observable.create(this, 0);
constructor(private _appModel: AppModel) {
super();
this.autoDispose(this._stepIndex.addListener((_, prevIndex) => {
this._steps[prevIndex].onNavigateAway?.();
}));
const incrementStep: IncrementStep = (delta: -1 | 1 = 1) => {
this._stepIndex.set(this._stepIndex.get() + delta);
};
this._steps = [
{
state: {
organization: Observable.create(this, ''),
role: Observable.create(this, ''),
useCases: choices.map(() => Observable.create(this, false)),
useOther: Observable.create(this, ''),
},
buildDom() { return dom.create(buildQuestions, incrementStep, this.state as QuestionsState); },
onNavigateAway() { saveQuestions(this.state as QuestionsState); },
},
{
state: {
watched: Observable.create(this, false),
},
buildDom() { return dom.create(buildVideo, incrementStep, this.state as VideoState); },
},
{
buildDom() { return dom.create(buildTutorial, incrementStep); },
},
];
document.title = `Welcome${getPageTitleSuffix(getGristConfig())}`;
getUserPrefObs(this._appModel.userPrefsObs, 'showNewUserQuestions').set(undefined);
}
public buildDom() {
return cssPageContainer(
cssOnboardingPage(
cssSidebar(
cssSidebarContent(
cssSidebarHeading1(t('Welcome')),
cssSidebarHeading2(this._appModel.currentUser!.name + '!'),
testId('sidebar'),
),
cssGetStarted(
cssGetStartedImg({src: 'img/get-started.png'}),
),
),
cssMainPanel(
buildStepper(this._steps, this._stepIndex),
dom.domComputed(this._stepIndex, index => {
return this._steps[index].buildDom();
}),
),
testId('page'),
),
);
}
}
function buildStepper(steps: Step[], stepIndex: Observable<number>) {
return cssStepper(
steps.map((_, i) =>
cssStep(
cssStepCircle(
cssStepCircle.cls('-done', use => (i < use(stepIndex))),
dom.domComputed(use => i < use(stepIndex), (done) => done ? icon('Tick') : String(i + 1)),
cssStepCircle.cls('-current', use => (i === use(stepIndex))),
dom.on('click', () => { stepIndex.set(i); }),
testId(`step-${i + 1}`)
)
)
)
);
}
function saveQuestions(state: QuestionsState) {
const {organization, role, useCases, useOther} = state;
if (!organization.get() && !role.get() && !useCases.map(useCase => useCase.get()).includes(true)) {
return;
}
const org_name = organization.get();
const org_role = role.get();
const use_cases = choices.filter((c, i) => useCases[i].get()).map(c => c.textKey);
const use_other = use_cases.includes('Other') ? useOther.get() : '';
const submitUrl = new URL(window.location.href);
submitUrl.pathname = '/welcome/info';
BaseAPI.request(submitUrl.href, {
method: 'POST',
body: JSON.stringify({org_name, org_role, use_cases, use_other})
}).catch((e) => logError(e));
}
function buildQuestions(owner: IDisposableOwner, incrementStep: IncrementStep, state: QuestionsState) {
const {organization, role, useCases, useOther} = state;
const isFilled = Computed.create(owner, (use) => {
return Boolean(use(organization) || use(role) || useCases.map(useCase => use(useCase)).includes(true));
});
return cssQuestions(
cssHeading(t("Tell us who you are")),
cssQuestion(
cssFieldHeading(t('What organization are you with?')),
cssInput(
organization,
{type: 'text', placeholder: t('Your organization')},
testId('questions-organization'),
),
),
cssQuestion(
cssFieldHeading(t('What is your role?')),
cssInput(
role,
{type: 'text', placeholder: t('Your role')},
testId('questions-role'),
),
),
cssQuestion(
cssFieldHeading(t("What brings you to Grist (you can select multiple)?")),
cssUseCases(
choices.map((item, i) => cssUseCase(
cssUseCaseIcon(icon(item.icon)),
cssUseCase.cls('-selected', useCases[i]),
dom.on('click', () => useCases[i].set(!useCases[i].get())),
(item.icon !== 'UseOther' ?
t(item.textKey) :
[
cssOtherLabel(t(item.textKey)),
cssOtherInput(useOther, {}, {type: 'text', placeholder: t("Type here")},
// The following subscribes to changes to selection observable, and focuses the input when
// this item is selected.
(elem) => subscribeElem(elem, useCases[i], val => val && setTimeout(() => elem.focus(), 0)),
// It's annoying if clicking into the input toggles selection; better to turn that
// off (user can click icon to deselect).
dom.on('click', ev => ev.stopPropagation()),
// Similarly, ignore Enter/Escape in "Other" textbox, so that they don't submit/close the form.
dom.onKeyDown({
Enter: (ev, elem) => elem.blur(),
Escape: (ev, elem) => elem.blur(),
}),
)
]
),
testId('questions-use-case'),
)),
),
),
cssContinue(
bigPrimaryButton(
t('Next step'),
dom.show(isFilled),
dom.on('click', () => incrementStep()),
testId('next-step'),
),
bigBasicButton(
t('Skip step'),
dom.hide(isFilled),
dom.on('click', () => incrementStep()),
testId('skip-step'),
),
),
testId('questions'),
);
}
function buildVideo(_owner: IDisposableOwner, incrementStep: IncrementStep, state: VideoState) {
const {watched} = state;
function onPlay() {
watched.set(true);
return modal((ctl, modalOwner) => {
const youtubePlayer = YouTubePlayer.create(modalOwner,
ONBOARDING_VIDEO_YOUTUBE_EMBED_ID,
{
onPlayerReady: (player) => player.playVideo(),
onPlayerStateChange(_player, {data}) {
if (data !== PlayerState.Ended) { return; }
ctl.close();
},
height: '100%',
width: '100%',
origin: getMainOrgUrl(),
},
cssYouTubePlayer.cls(''),
);
return [
dom.on('click', () => ctl.close()),
elem => { FocusLayer.create(modalOwner, {defaultFocusElem: elem, pauseMousetrap: true}); },
dom.onKeyDown({
Escape: () => ctl.close(),
' ': () => youtubePlayer.playPause(),
}),
cssModalHeader(
cssModalCloseButton(
cssCloseIcon('CrossBig'),
),
),
cssModalBody(
cssVideoPlayer(
dom.on('click', (ev) => ev.stopPropagation()),
youtubePlayer.buildDom(),
testId('video-player'),
),
cssModalButtons(
bigPrimaryButton(
t('Next step'),
dom.on('click', (ev) => {
ev.stopPropagation();
ctl.close();
incrementStep();
}),
),
),
),
cssVideoPlayerModal.cls(''),
];
});
}
return dom('div',
cssHeading(t('Discover Grist in 3 minutes')),
cssScreenshot(
dom.on('click', onPlay),
dom('div',
cssScreenshotImg({src: 'img/youtube-screenshot.png'}),
cssActionOverlay(
cssAction(
cssRoundButton(cssVideoPlayIcon('VideoPlay')),
),
),
),
testId('video-thumbnail'),
),
cssContinue(
cssBackButton(
t('Back'),
dom.on('click', () => incrementStep(-1)),
testId('back'),
),
bigPrimaryButton(
t('Next step'),
dom.show(watched),
dom.on('click', () => incrementStep()),
testId('next-step'),
),
bigBasicButton(
t('Skip step'),
dom.hide(watched),
dom.on('click', () => incrementStep()),
testId('skip-step'),
),
),
testId('video'),
);
}
function buildTutorial(_owner: IDisposableOwner, incrementStep: IncrementStep) {
const {templateOrg, onboardingTutorialDocId} = getGristConfig();
return dom('div',
cssHeading(
t('Go hands-on with the Grist Basics tutorial'),
cssSubHeading(
t("Grist may look like a spreadsheet, but it doesn't always "
+ "act like one. Discover what makes Grist different."
),
),
),
cssTutorial(
cssScreenshot(
dom.on('click', () => urlState().pushUrl({org: templateOrg!, doc: onboardingTutorialDocId})),
cssTutorialScreenshotImg({src: 'img/tutorial-screenshot.png'}),
cssTutorialOverlay(
cssAction(
cssTutorialButton(t('Go to the tutorial!')),
),
),
testId('tutorial-thumbnail'),
),
),
cssContinue(
cssBackButton(
t('Back'),
dom.on('click', () => incrementStep(-1)),
testId('back'),
),
bigBasicButton(
t('Skip tutorial'),
dom.on('click', () => window.location.href = urlState().makeUrl(urlState().state.get())),
testId('skip-tutorial'),
),
),
testId('tutorial'),
);
}
const cssPageContainer = styled('div', `
overflow: auto;
height: 100%;
background-color: ${theme.mainPanelBg};
`);
const cssOnboardingPage = styled('div', `
display: flex;
min-height: 100%;
`);
const cssSidebar = styled('div', `
width: 460px;
background-color: ${colors.lightGreen};
color: ${colors.light};
background-image:
linear-gradient(to bottom, rgb(41, 185, 131) 32px, transparent 32px),
linear-gradient(to right, rgb(41, 185, 131) 32px, transparent 32px);
background-size: 240px 120px;
background-position: 0 0, 40%;
display: flex;
flex-direction: column;
@media ${mediaMedium} {
& {
display: none;
}
}
`);
const cssGetStarted = styled('div', `
width: 500px;
height: 350px;
margin: auto -77px 0 37px;
overflow: hidden;
`);
const cssGetStartedImg = styled('img', `
display: block;
width: 500px;
height: auto;
`);
const cssSidebarContent = styled('div', `
line-height: 32px;
margin: 112px 16px 64px 16px;
font-size: 24px;
line-height: 48px;
font-weight: 500;
`);
const cssSidebarHeading1 = styled('div', `
font-size: 32px;
text-align: center;
`);
const cssSidebarHeading2 = styled('div', `
font-size: 28px;
text-align: center;
`);
const cssMainPanel = styled('div', `
margin: 56px auto;
padding: 0px 96px;
text-align: center;
@media ${mediaMedium} {
& {
padding: 0px 32px;
}
}
`);
const cssHeading = styled('div', `
color: ${theme.text};
font-size: 24px;
font-weight: 500;
margin: 32px 0px;
`);
const cssSubHeading = styled(cssHeading, `
font-size: 15px;
font-weight: 400;
margin-top: 16px;
`);
const cssStep = styled('div', `
display: flex;
align-items: center;
cursor: default;
&:not(:last-child)::after {
content: "";
width: 50px;
height: 2px;
background-color: var(--grist-color-light-green);
}
`);
const cssStepCircle = styled('div', `
--icon-color: ${theme.controlPrimaryFg};
--step-color: ${theme.controlPrimaryBg};
display: inline-block;
width: 24px;
height: 24px;
border-radius: 30px;
border: 1px solid var(--step-color);
color: var(--step-color);
margin: 4px;
position: relative;
cursor: pointer;
&:hover {
--step-color: ${theme.controlPrimaryHoverBg};
}
&-current {
background-color: var(--step-color);
color: ${theme.controlPrimaryFg};
outline: 3px solid ${theme.cursorInactive};
}
&-done {
background-color: var(--step-color);
}
`);
const cssQuestions = styled('div', `
max-width: 500px;
`);
const cssQuestion = styled('div', `
margin: 16px 0 8px 0;
text-align: left;
`);
const cssFieldHeading = styled('div', `
color: ${theme.text};
font-size: 13px;
font-weight: 700;
margin-bottom: 12px;
`);
const cssContinue = styled('div', `
display: flex;
justify-content: center;
margin-top: 40px;
gap: 16px;
`);
const cssUseCases = styled('div', `
display: flex;
flex-wrap: wrap;
align-items: center;
margin: -8px -4px;
`);
const cssUseCase = styled('div', `
flex: 1 0 40%;
min-width: 200px;
margin: 8px 4px 0 4px;
height: 40px;
border: 1px solid ${theme.inputBorder};
border-radius: 3px;
display: flex;
align-items: center;
text-align: left;
cursor: pointer;
color: ${theme.text};
--icon-color: ${theme.accentIcon};
&:hover {
background-color: ${theme.hover};
}
&-selected {
border: 2px solid ${theme.controlFg};
}
&-selected:hover {
border: 2px solid ${theme.controlHoverFg};
}
&-selected:focus-within {
box-shadow: 0 0 2px 0px ${theme.controlFg};
}
`);
const cssUseCaseIcon = styled('div', `
margin: 0 16px;
--icon-color: ${theme.accentIcon};
`);
const cssOtherLabel = styled('div', `
display: block;
.${cssUseCase.className}-selected & {
display: none;
}
`);
const cssInput = styled(textInput, `
height: 40px;
`);
const cssOtherInput = styled(input, `
color: ${theme.inputFg};
display: none;
border: none;
background: none;
outline: none;
padding: 0px;
&::placeholder {
color: ${theme.inputPlaceholderFg};
}
.${cssUseCase.className}-selected & {
display: block;
}
`);
const cssTutorial = styled('div', `
display: flex;
justify-content: center;
`);
const cssScreenshot = styled('div', `
max-width: 720px;
display: flex;
position: relative;
border-radius: 3px;
border: 3px solid ${colors.lightGreen};
overflow: hidden;
cursor: pointer;
`);
const cssActionOverlay = styled('div', `
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.20);
`);
const cssTutorialOverlay = styled(cssActionOverlay, `
background-color: transparent;
`);
const cssAction = styled('div', `
display: flex;
flex-direction: column;
margin: auto;
align-items: center;
justify-content: center;
height: 100%;
`);
const cssVideoPlayIcon = styled(icon, `
--icon-color: ${colors.light};
width: 38px;
height: 33.25px;
`);
const cssCloseIcon = styled(icon, `
--icon-color: ${colors.light};
width: 22px;
height: 22px;
`);
const cssYouTubePlayer = styled('iframe', `
border-radius: 4px;
`);
const cssModalHeader = styled('div', `
display: flex;
flex-shrink: 0;
justify-content: flex-end;
`);
const cssModalBody = styled('div', `
display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: center;
align-items: center;
`);
const cssBackButton = styled(bigBasicButton, `
border: none;
`);
const cssModalButtons = styled('div', `
display: flex;
justify-content: center;
margin-top: 24px;
`);
const cssVideoPlayer = styled('div', `
width: 100%;
max-width: 1280px;
height: 100%;
max-height: 720px;
@media ${mediaXSmall} {
& {
max-height: 240px;
}
}
`);
const cssVideoPlayerModal = styled('div', `
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 8px;
background-color: transparent;
box-shadow: none;
`);
const cssModalCloseButton = styled('div', `
margin-bottom: 8px;
padding: 4px;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: ${theme.hover};
}
`);
const cssScreenshotImg = styled('img', `
transform: scale(1.2);
width: 100%;
`);
const cssTutorialScreenshotImg = styled('img', `
width: 100%;
opacity: 0.4;
`);
const cssRoundButton = styled('div', `
width: 75px;
height: 75px;
flex-shrink: 0;
border-radius: 100px;
background: ${colors.lightGreen};
display: flex;
align-items: center;
justify-content: center;
--icon-color: var(--light, #FFF);
.${cssScreenshot.className}:hover & {
background: ${colors.darkGreen};
}
`);
const cssStepper = styled('div', `
display: flex;
justify-content: center;
text-align: center;
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: 20px;
text-transform: uppercase;
`);
const cssTutorialButton = styled(bigPrimaryButtonLink, `
.${cssScreenshot.className}:hover & {
background-color: ${theme.controlPrimaryHoverBg};
border-color: ${theme.controlPrimaryHoverBg};
}
`);

View File

@@ -1,4 +1,3 @@
import * as commands from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {getMainOrgUrl} from 'app/client/models/gristUrlState';
@@ -7,15 +6,13 @@ import {YouTubePlayer} from 'app/client/ui/YouTubePlayer';
import {theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssModalCloseButton, modal} from 'app/client/ui2018/modals';
import {isFeatureEnabled} from 'app/common/gristUrls';
import {dom, makeTestId, styled} from 'grainjs';
import {isFeatureEnabled, ONBOARDING_VIDEO_YOUTUBE_EMBED_ID} from 'app/common/gristUrls';
import {dom, keyframes, makeTestId, styled} from 'grainjs';
const t = makeT('OpenVideoTour');
const testId = makeTestId('test-video-tour-');
const VIDEO_TOUR_YOUTUBE_EMBED_ID = '56AieR9rpww';
/**
* Opens a modal containing a video tour of Grist.
*/
@@ -23,7 +20,7 @@ const VIDEO_TOUR_YOUTUBE_EMBED_ID = '56AieR9rpww';
return modal(
(ctl, owner) => {
const youtubePlayer = YouTubePlayer.create(owner,
VIDEO_TOUR_YOUTUBE_EMBED_ID,
ONBOARDING_VIDEO_YOUTUBE_EMBED_ID,
{
onPlayerReady: (player) => player.playVideo(),
height: '100%',
@@ -83,12 +80,7 @@ export function createVideoTourToolsButton(): HTMLDivElement | null {
let iconElement: HTMLElement;
const commandsGroup = commands.createGroup({
videoTourToolsOpen: () => openVideoTour(iconElement),
}, null, true);
return cssPageEntryMain(
dom.autoDispose(commandsGroup),
cssPageLink(
iconElement = cssPageIcon('Video'),
cssLinkText(t("Video Tour")),
@@ -108,10 +100,19 @@ const cssModal = styled('div', `
max-width: 864px;
`);
const delayedVisibility = keyframes(`
to {
visibility: visible;
}
`);
const cssYouTubePlayerContainer = styled('div', `
position: relative;
padding-bottom: 56.25%;
height: 0;
/* Wait until the modal is finished animating. */
visibility: hidden;
animation: 0s linear 0.4s forwards ${delayedVisibility};
`);
const cssYouTubePlayer = styled('div', `

View File

@@ -1,217 +0,0 @@
import {AppModel} from 'app/client/models/AppModel';
import {bigPrimaryButton} from 'app/client/ui2018/buttons';
import {isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {Computed, dom, IDisposableOwner, makeTestId, styled} from 'grainjs';
const testId = makeTestId('test-tutorial-card-');
interface Options {
app: AppModel,
}
export function buildTutorialCard(owner: IDisposableOwner, options: Options) {
if (!isFeatureEnabled('tutorials')) { return null; }
const {app} = options;
function onClose() {
app.dismissPopup('tutorialFirstCard', true);
}
const visible = Computed.create(owner, (use) =>
!use(app.dismissedPopups).includes('tutorialFirstCard')
&& !use(isNarrowScreenObs())
);
return dom.maybe(visible, () => {
return cssCard(
cssCaption(
dom('div', cssNewToGrist("New to Grist?")),
cssRelative(
cssStartHere("Start here."),
cssArrow()
),
),
cssContent(
testId('content'),
cssImage({src: commonUrls.basicTutorialImage}),
cssCardText(
cssLine(cssTitle("Grist Basics Tutorial")),
cssLine("Learn the basics of reference columns, linked widgets, column types, & cards."),
cssLine(cssSub('Beginner - 10 mins')),
cssButtonWrapper(
cssButtonWrapper.cls('-small'),
cssHeroButton("Start Tutorial"),
{href: commonUrls.basicTutorial, target: '_blank'},
),
),
),
cssButtonWrapper(
cssButtonWrapper.cls('-big'),
cssHeroButton("Start Tutorial"),
{href: commonUrls.basicTutorial, target: '_blank'},
),
cssCloseButton(icon('CrossBig'), dom.on('click', () => onClose?.()), testId('close')),
);
});
}
const cssContent = styled('div', `
position: relative;
display: flex;
align-items: flex-start;
padding-top: 24px;
padding-bottom: 20px;
padding-right: 20px;
max-width: 460px;
`);
const cssCardText = styled('div', `
display: flex;
flex-direction: column;
justify-content: center;
align-self: stretch;
margin-left: 12px;
`);
const cssRelative = styled('div', `
position: relative;
`);
const cssNewToGrist = styled('span', `
font-style: normal;
font-weight: 400;
font-size: 24px;
line-height: 16px;
letter-spacing: 0.2px;
white-space: nowrap;
`);
const cssStartHere = styled('span', `
font-style: normal;
font-weight: 700;
font-size: 24px;
line-height: 16px;
letter-spacing: 0.2px;
white-space: nowrap;
`);
const cssCaption = styled('div', `
display: flex;
flex-direction: column;
gap: 12px;
margin-left: 32px;
margin-top: 42px;
margin-right: 64px;
`);
const cssTitle = styled('span', `
font-weight: 600;
font-size: 20px;
`);
const cssSub = styled('span', `
font-size: 12px;
color: ${theme.lightText};
`);
const cssLine = styled('div', `
margin-bottom: 6px;
`);
const cssHeroButton = styled(bigPrimaryButton, `
`);
const cssButtonWrapper = styled('a', `
flex-grow: 1;
display: flex;
justify-content: flex-end;
margin-right: 60px;
align-items: center;
text-decoration: none;
&:hover {
text-decoration: none;
}
&-big .${cssHeroButton.className} {
padding: 16px 28px;
font-weight: 600;
font-size: 20px;
line-height: 1em;
}
`);
const cssCloseButton = styled('div', `
flex-shrink: 0;
align-self: flex-end;
cursor: pointer;
--icon-color: ${theme.controlSecondaryFg};
margin: 8px 8px 4px 0px;
padding: 2px;
border-radius: 4px;
position: absolute;
top: 0;
right: 0;
&:hover {
background-color: ${theme.lightHover};
}
&:active {
background-color: ${theme.hover};
}
`);
const cssImage = styled('img', `
width: 187px;
height: 145px;
flex: none;
`);
const cssArrow = styled('div', `
position: absolute;
background-image: var(--icon-GreenArrow);
width: 94px;
height: 12px;
top: calc(50% - 6px);
left: calc(100% - 12px);
z-index: 1;
`);
const cssCard = styled('div', `
display: flex;
position: relative;
color: ${theme.text};
border-radius: 3px;
margin-bottom: 24px;
max-width: 1000px;
box-shadow: 0 2px 18px 0 ${theme.modalInnerShadow}, 0 0 1px 0 ${theme.modalOuterShadow};
& .${cssButtonWrapper.className}-small {
display: none;
}
@media (max-width: 1320px) {
& .${cssButtonWrapper.className}-small {
flex-direction: column;
display: flex;
margin-top: 14px;
align-self: flex-start;
}
& .${cssButtonWrapper.className}-big {
display: none;
}
}
@media (max-width: 1000px) {
& .${cssArrow.className} {
display: none;
}
& .${cssCaption.className} {
flex-direction: row;
margin-bottom: 24px;
}
& {
flex-direction: column;
}
& .${cssContent.className} {
padding: 12px;
max-width: 100%;
margin-bottom: 28px;
}
}
`);

View File

@@ -1,176 +0,0 @@
import {makeT} from 'app/client/lib/localization';
import * as commands from 'app/client/components/commands';
import {getUserPrefObs} from 'app/client/models/UserPrefs';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {IconName} from 'app/client/ui2018/IconList';
import {ISaveModalOptions, saveModal} from 'app/client/ui2018/modals';
import {BaseAPI} from 'app/common/BaseAPI';
import {UserPrefs} from 'app/common/Prefs';
import {getGristConfig} from 'app/common/urlUtils';
import {dom, input, Observable, styled, subscribeElem} from 'grainjs';
const t = makeT('WelcomeQuestions');
export function shouldShowWelcomeQuestions(userPrefsObs: Observable<UserPrefs>): boolean {
return Boolean(getGristConfig().survey && userPrefsObs.get()?.showNewUserQuestions);
}
/**
* Shows a modal with welcome questions if surveying is enabled and the user hasn't
* dismissed the modal before.
*/
export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
saveModal((ctl, owner): ISaveModalOptions => {
const selection = choices.map(c => Observable.create(owner, false));
const otherText = Observable.create(owner, '');
const showQuestions = getUserPrefObs(userPrefsObs, 'showNewUserQuestions');
async function onConfirm() {
const use_cases = choices.filter((c, i) => selection[i].get()).map(c => c.textKey);
const use_other = use_cases.includes("Other") ? otherText.get() : '';
const submitUrl = new URL(window.location.href);
submitUrl.pathname = '/welcome/info';
return BaseAPI.request(submitUrl.href,
{method: 'POST', body: JSON.stringify({use_cases, use_other})});
}
owner.onDispose(async () => {
// Whichever way the modal is closed, don't show the questions again. (We set the value to
// undefined to remove it from the JSON prefs object entirely; it's never used again.)
showQuestions.set(undefined);
// Show the Grist video tour when the modal is closed.
await commands.allCommands.leftPanelOpen.run();
commands.allCommands.videoTourToolsOpen.run();
});
return {
title: [cssLogo(), dom('div', t("Welcome to Grist!"))],
body: buildInfoForm(selection, otherText),
saveLabel: 'Start using Grist',
saveFunc: onConfirm,
hideCancel: true,
width: 'fixed-wide',
modalArgs: cssModalCentered.cls(''),
};
});
}
const choices: Array<{icon: IconName, color: string, textKey: string}> = [
{icon: 'UseProduct', color: `${colors.lightGreen}`, textKey: 'Product Development' },
{icon: 'UseFinance', color: '#0075A2', textKey: 'Finance & Accounting' },
{icon: 'UseMedia', color: '#F7B32B', textKey: 'Media Production' },
{icon: 'UseMonitor', color: '#F2545B', textKey: 'IT & Technology' },
{icon: 'UseChart', color: '#7141F9', textKey: 'Marketing' },
{icon: 'UseScience', color: '#231942', textKey: 'Research' },
{icon: 'UseSales', color: '#885A5A', textKey: 'Sales' },
{icon: 'UseEducate', color: '#4A5899', textKey: 'Education' },
{icon: 'UseHr', color: '#688047', textKey: 'HR & Management' },
{icon: 'UseOther', color: '#929299', textKey: 'Other' },
];
function buildInfoForm(selection: Observable<boolean>[], otherText: Observable<string>) {
return [
dom('span', t("What brings you to Grist? Please help us serve you better.")),
cssChoices(
choices.map((item, i) => cssChoice(
cssIcon(icon(item.icon), {style: `--icon-color: ${item.color}`}),
cssChoice.cls('-selected', selection[i]),
dom.on('click', () => selection[i].set(!selection[i].get())),
(item.icon !== 'UseOther' ?
t(item.textKey) :
[
cssOtherLabel(t(item.textKey)),
cssOtherInput(otherText, {}, {type: 'text', placeholder: t("Type here")},
// The following subscribes to changes to selection observable, and focuses the input when
// this item is selected.
(elem) => subscribeElem(elem, selection[i], val => val && setTimeout(() => elem.focus(), 0)),
// It's annoying if clicking into the input toggles selection; better to turn that
// off (user can click icon to deselect).
dom.on('click', ev => ev.stopPropagation()),
// Similarly, ignore Enter/Escape in "Other" textbox, so that they don't submit/close the form.
dom.onKeyDown({
Enter: (ev, elem) => elem.blur(),
Escape: (ev, elem) => elem.blur(),
}),
)
]
)
)),
testId('welcome-questions'),
),
];
}
const cssModalCentered = styled('div', `
text-align: center;
`);
const cssLogo = styled('div', `
display: inline-block;
height: 48px;
width: 48px;
background-image: var(--icon-GristLogo);
background-size: 32px 32px;
background-repeat: no-repeat;
background-position: center;
`);
const cssChoices = styled('div', `
display: flex;
flex-wrap: wrap;
align-items: center;
margin-top: 24px;
`);
const cssChoice = styled('div', `
flex: 1 0 40%;
min-width: 0px;
margin: 8px 4px 0 4px;
height: 40px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
display: flex;
align-items: center;
text-align: left;
cursor: pointer;
&:hover {
border-color: ${colors.lightGreen};
}
&-selected {
background-color: ${colors.mediumGrey};
}
&-selected:hover {
border-color: ${colors.darkGreen};
}
&-selected:focus-within {
box-shadow: 0 0 2px 0px var(--grist-color-cursor);
border-color: ${colors.lightGreen};
}
`);
const cssIcon = styled('div', `
margin: 0 16px;
`);
const cssOtherLabel = styled('div', `
display: block;
.${cssChoice.className}-selected & {
display: none;
}
`);
const cssOtherInput = styled(input, `
display: none;
border: none;
background: none;
outline: none;
padding: 0px;
.${cssChoice.className}-selected & {
display: block;
}
`);

View File

@@ -11,6 +11,7 @@ export interface Player {
unMute(): void;
setVolume(volume: number): void;
getCurrentTime(): number;
getPlayerState(): PlayerState;
}
export interface PlayerOptions {
@@ -93,6 +94,18 @@ export class YouTubePlayer extends Disposable {
this._player.playVideo();
}
public pause() {
this._player.pauseVideo();
}
public playPause() {
if (this._player.getPlayerState() === PlayerState.Playing) {
this._player.pauseVideo();
} else {
this._player.playVideo();
}
}
public setVolume(volume: number) {
this._player.setVolume(volume);
}

View File

@@ -133,13 +133,17 @@ export type IconName = "ChartArea" |
"Separator" |
"Settings" |
"Share" |
"Skip" |
"Sort" |
"Sparks" |
"Star" |
"Tick" |
"TickSolid" |
"Undo" |
"Validation" |
"Video" |
"VideoPlay" |
"VideoPlay2" |
"Warning" |
"Widget" |
"Wrap" |
@@ -290,13 +294,17 @@ export const IconList: IconName[] = ["ChartArea",
"Separator",
"Settings",
"Share",
"Skip",
"Sort",
"Sparks",
"Star",
"Tick",
"TickSolid",
"Undo",
"Validation",
"Video",
"VideoPlay",
"VideoPlay2",
"Warning",
"Widget",
"Wrap",

View File

@@ -107,12 +107,15 @@ export interface BehavioralPromptPrefs {
export const DismissedPopup = StringUnion(
'deleteRecords', // confirmation for deleting records keyboard shortcut
'deleteFields', // confirmation for deleting columns keyboard shortcut
'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
'publishForm', // confirmation for publishing a form
'unpublishForm', // confirmation for unpublishing a form
'onboardingCards', // onboarding cards shown on the doc menu
/* Deprecated */
'tutorialFirstCard', // first card of the tutorial
);
export type DismissedPopup = typeof DismissedPopup.type;

View File

@@ -144,7 +144,7 @@ export interface DocumentOptions {
export interface TutorialMetadata {
lastSlideIndex?: number;
numSlides?: number;
percentComplete?: number;
}
export interface DocumentProperties extends CommonProperties {
@@ -368,6 +368,7 @@ export interface UserAPI {
getOrgWorkspaces(orgId: number|string, includeSupport?: boolean): Promise<Workspace[]>;
getOrgUsageSummary(orgId: number|string): Promise<OrgUsageSummary>;
getTemplates(onlyFeatured?: boolean): Promise<Workspace[]>;
getTemplate(docId: string): Promise<Document>;
getDoc(docId: string): Promise<Document>;
newOrg(props: Partial<OrganizationProperties>): Promise<number>;
newWorkspace(props: Partial<WorkspaceProperties>, orgId: number|string): Promise<number>;
@@ -587,6 +588,10 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
return this.requestJson(`${this._url}/api/templates?onlyFeatured=${onlyFeatured ? 1 : 0}`, { method: 'GET' });
}
public async getTemplate(docId: string): Promise<Document> {
return this.requestJson(`${this._url}/api/templates/${docId}`, { method: 'GET' });
}
public async getWidgets(): Promise<ICustomWidget[]> {
return await this.requestJson(`${this._url}/api/widgets`, { method: 'GET' });
}

View File

@@ -101,8 +101,6 @@ export const commonUrls = {
formulas: 'https://support.getgrist.com/formulas',
forms: 'https://www.getgrist.com/forms/?utm_source=grist-forms&utm_medium=grist-forms&utm_campaign=forms-footer',
basicTutorial: 'https://templates.getgrist.com/woXtXUBmiN5T/Grist-Basics',
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',
@@ -111,6 +109,8 @@ export const commonUrls = {
versionCheck: 'https://api.getgrist.com/api/version',
};
export const ONBOARDING_VIDEO_YOUTUBE_EMBED_ID = '56AieR9rpww';
/**
* Values representable in a URL. The current state is available as urlState().state observable
* in client. Updates to this state are expected by functions such as makeUrl() and setLinkUrl().
@@ -811,6 +811,9 @@ export interface GristLoadConfig {
// The org containing public templates and tutorials.
templateOrg?: string|null;
// The doc id of the tutorial shown during onboarding.
onboardingTutorialDocId?: string;
// Whether to show the "Delete Account" button in the account page.
canCloseAccount?: boolean;

View File

@@ -302,6 +302,18 @@ export class ApiServer {
return sendReply(req, res, query);
}));
// GET /api/templates/:did
// Get information about a template.
this._app.get('/api/templates/:did', expressWrap(async (req, res) => {
const templateOrg = getTemplateOrg();
if (!templateOrg) {
throw new ApiError('Template org is not configured', 501);
}
const query = await this._dbManager.getDoc({...getScope(req), org: templateOrg});
return sendOkReply(req, res, query);
}));
// GET /api/widgets/
// Get all widget definitions from external source.
this._app.get('/api/widgets/', expressWrap(async (req, res) => {

View File

@@ -134,12 +134,12 @@ export class Document extends Resource {
this.options.tutorial = null;
} else {
this.options.tutorial = this.options.tutorial || {};
if (props.options.tutorial.numSlides !== undefined) {
this.options.tutorial.numSlides = props.options.tutorial.numSlides;
}
if (props.options.tutorial.lastSlideIndex !== undefined) {
this.options.tutorial.lastSlideIndex = props.options.tutorial.lastSlideIndex;
}
if (props.options.tutorial.percentComplete !== undefined) {
this.options.tutorial.percentComplete = props.options.tutorial.percentComplete;
}
}
}
// Normalize so that null equates with absence.

View File

@@ -1124,7 +1124,7 @@ export class DocWorkerApi {
const scope = getDocScope(req);
const tutorialTrunkId = options.sourceDocId;
await this._dbManager.connection.transaction(async (manager) => {
// Fetch the tutorial trunk doc so we can replace the tutorial doc's name.
// Fetch the tutorial trunk so we can replace the tutorial fork's name.
const tutorialTrunk = await this._dbManager.getDoc({...scope, urlId: tutorialTrunkId}, manager);
await this._dbManager.updateDocument(
scope,
@@ -1132,9 +1132,8 @@ export class DocWorkerApi {
name: tutorialTrunk.name,
options: {
tutorial: {
...tutorialTrunk.options?.tutorial,
// For now, the only state we need to reset is the slide position.
lastSlideIndex: 0,
percentComplete: 0,
},
},
},

View File

@@ -1055,7 +1055,7 @@ export class FlexServer implements GristServer {
// Reset isFirstTimeUser flag.
await this._dbManager.updateUser(user.id, {isFirstTimeUser: false});
// This is a good time to set some other flags, for showing a popup with welcome question(s)
// This is a good time to set some other flags, for showing a page with welcome question(s)
// to this new user and recording their sign-up with Google Tag Manager. These flags are also
// scoped to the user, but isFirstTimeUser has a dedicated DB field because it predates userPrefs.
// Note that the updateOrg() method handles all levels of prefs (for user, user+org, or org).
@@ -1586,20 +1586,25 @@ export class FlexServer implements GristServer {
this.app.post('/welcome/info', ...middleware, expressWrap(async (req, resp, next) => {
const userId = getUserId(req);
const user = getUser(req);
const orgName = stringParam(req.body.org_name, 'org_name');
const orgRole = stringParam(req.body.org_role, 'org_role');
const useCases = stringArrayParam(req.body.use_cases, 'use_cases');
const useOther = stringParam(req.body.use_other, 'use_other');
const row = {
UserID: userId,
Name: user.name,
Email: user.loginEmail,
org_name: orgName,
org_role: orgRole,
use_cases: ['L', ...useCases],
use_other: useOther,
};
this._recordNewUserInfo(row)
.catch(e => {
try {
await this._recordNewUserInfo(row);
} catch (e) {
// If we failed to record, at least log the data, so we could potentially recover it.
log.rawWarn(`Failed to record new user info: ${e.message}`, {newUserQuestions: row});
});
}
const nonOtherUseCases = useCases.filter(useCase => useCase !== 'Other');
for (const useCase of [...nonOtherUseCases, ...(useOther ? [`Other - ${useOther}`] : [])]) {
this.getTelemetry().logEvent(req as RequestWithLogin, 'answeredUseCaseQuestion', {

View File

@@ -11,3 +11,9 @@ export function getTemplateOrg() {
}
return org;
}
export function getOnboardingTutorialDocId() {
return appSettings.section('tutorials').flag('onboardingTutorialDocId').readString({
envVar: 'GRIST_ONBOARDING_TUTORIAL_DOC_ID',
});
}

View File

@@ -16,7 +16,7 @@ import {SUPPORT_EMAIL} from 'app/gen-server/lib/homedb/HomeDBManager';
import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {GristServer} from 'app/server/lib/GristServer';
import {getTemplateOrg} from 'app/server/lib/gristSettings';
import {getOnboardingTutorialDocId, getTemplateOrg} from 'app/server/lib/gristSettings';
import {getSupportedEngineChoices} from 'app/server/lib/serverUtils';
import {readLoadedLngs, readLoadedNamespaces} from 'app/server/localization';
import * as express from 'express';
@@ -97,6 +97,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi
telemetry: server?.getTelemetry().getTelemetryConfig(req as RequestWithLogin | undefined),
deploymentType: server?.getDeploymentType(),
templateOrg: getTemplateOrg(),
onboardingTutorialDocId: getOnboardingTutorialDocId(),
canCloseAccount: isAffirmative(process.env.GRIST_ACCOUNT_CLOSE),
experimentalPlugins: isAffirmative(process.env.GRIST_EXPERIMENTAL_PLUGINS),
notifierEnabled: server?.hasNotifier(),