(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
pull/1118/head
George Gevoian 2 months ago
parent 3fd8719d8a
commit 4740f1f933

@ -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: [],

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

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

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

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

@ -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),
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')
/* 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)")),
]),
// 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(

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

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

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

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

@ -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', `

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

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

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

@ -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",

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

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

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

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

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

@ -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,
},
},
},

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

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

@ -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(),

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

@ -1,7 +1,12 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.1">
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.5402 0H18.0047C8.06752 0 0 8.9774 0 20.0353V40H21.7051C31.5487 40 39.5402 31.1068 39.5402 20.1531V0Z" fill="#F9AE41"/>
<g clip-path="url(#clip0_1349_6885)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.5402 0H18.0047C8.06752 0 0 8.9774 0 20.0353V40H21.7051C31.5487 40 39.5402 31.1068 39.5402 20.1531V0Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4917 27.9306L29.453 18.8307C29.652 18.5268 29.5776 18.1119 29.2866 17.904C29.1805 17.8281 29.0548 17.7875 28.9262 17.7875H24.279V11.9948C24.279 11.6266 23.9932 11.3281 23.6407 11.3281C23.43 11.3281 23.2329 11.4367 23.1139 11.6183L17.1526 20.7183C16.9535 21.0221 17.028 21.437 17.3189 21.6449C17.4251 21.7208 17.5507 21.7614 17.6794 21.7614H22.3266V27.5542C22.3266 27.9223 22.6123 28.2208 22.9649 28.2208C23.1756 28.2208 23.3727 28.1122 23.4917 27.9306Z" fill="#F9AE41"/>
<path opacity="0.44" fill-rule="evenodd" clip-rule="evenodd" d="M17.1458 24.7419C17.8508 24.7419 18.4224 25.3388 18.4224 26.0752V26.3889C18.4224 27.1253 17.8508 27.7223 17.1458 27.7223H11.8892C11.1841 27.7223 10.6126 27.1253 10.6126 26.3889V26.0752C10.6126 25.3388 11.1841 24.7419 11.8892 24.7419H17.1458ZM14.2171 18.7811C14.9221 18.7811 15.4937 19.3781 15.4937 20.1144V20.4282C15.4937 21.1645 14.9221 21.7615 14.2171 21.7615H9.93675C9.23171 21.7615 8.66016 21.1645 8.66016 20.4282V20.1144C8.66016 19.3781 9.23171 18.7811 9.93675 18.7811H14.2171ZM17.1458 12.8203C17.8508 12.8203 18.4224 13.4173 18.4224 14.1536V14.4674C18.4224 15.2038 17.8508 15.8007 17.1458 15.8007H11.8892C11.1841 15.8007 10.6126 15.2038 10.6126 14.4674V14.1536C10.6126 13.4173 11.1841 12.8203 11.8892 12.8203H17.1458Z" fill="#F9AE41"/>
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.4917 27.9306L28.453 18.8307C28.652 18.5268 28.5776 18.1119 28.2866 17.904C28.1805 17.8281 28.0548 17.7875 27.9262 17.7875H23.279V11.9948C23.279 11.6266 22.9932 11.3281 22.6407 11.3281C22.43 11.3281 22.2329 11.4367 22.1139 11.6183L16.1526 20.7183C15.9535 21.0221 16.028 21.437 16.3189 21.6449C16.4251 21.7208 16.5507 21.7614 16.6794 21.7614H21.3266V27.5542C21.3266 27.9223 21.6123 28.2208 21.9649 28.2208C22.1756 28.2208 22.3727 28.1122 22.4917 27.9306Z" fill="#F9AE41"/>
<path opacity="0.44" fill-rule="evenodd" clip-rule="evenodd" d="M16.1453 24.7419C16.8503 24.7419 17.4219 25.3388 17.4219 26.0752V26.3889C17.4219 27.1253 16.8503 27.7223 16.1453 27.7223H10.8887C10.1837 27.7223 9.61211 27.1253 9.61211 26.3889V26.0752C9.61211 25.3388 10.1837 24.7419 10.8887 24.7419H16.1453ZM13.2166 18.7811C13.9217 18.7811 14.4932 19.3781 14.4932 20.1144V20.4282C14.4932 21.1645 13.9217 21.7615 13.2166 21.7615H8.93626C8.23122 21.7615 7.65967 21.1645 7.65967 20.4282V20.1144C7.65967 19.3781 8.23122 18.7811 8.93626 18.7811H13.2166ZM16.1453 12.8203C16.8503 12.8203 17.4219 13.4173 17.4219 14.1536V14.4674C17.4219 15.2038 16.8503 15.8007 16.1453 15.8007H10.8887C10.1837 15.8007 9.61211 15.2038 9.61211 14.4674V14.1536C9.61211 13.4173 10.1837 12.8203 10.8887 12.8203H16.1453Z" fill="#F9AE41"/>
<defs>
<clipPath id="clip0_1349_6885">
<rect width="40" height="40" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -1,5 +1,12 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.1" fill-rule="evenodd" clip-rule="evenodd" d="M39.5402 0H18.0047C8.06752 0 0 8.9774 0 20.0353V40H21.7051C31.5487 40 39.5402 31.1068 39.5402 20.1531V0Z" fill="#7141F9"/>
<path opacity="0.44" d="M13.8297 16.7786V23.223C13.8297 25.0026 15.211 26.4452 16.9148 26.4452H23.0851V27.324C23.0851 28.8431 22.2958 29.6675 20.8413 29.6675H12.9883C11.5339 29.6675 10.7446 28.8431 10.7446 27.324V19.122C10.7446 17.6029 11.5339 16.7786 12.9883 16.7786H13.8297ZM25.9832 11.4082C27.4376 11.4082 28.2269 12.2326 28.2269 13.7516V21.9537C28.2269 23.4727 27.4376 24.2971 25.9832 24.2971H25.1418V17.8526C25.1418 16.0731 23.7605 14.6304 22.0567 14.6304H15.8865V13.7516C15.8865 12.2326 16.6758 11.4082 18.1302 11.4082H25.9832Z" fill="#7141F9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.1956 16.7793H21.7765C22.6249 16.7793 23.0853 17.2602 23.0853 18.1463V22.9308C23.0853 23.8169 22.6249 24.2978 21.7765 24.2978H17.1956C16.3471 24.2978 15.8867 23.8169 15.8867 22.9308V18.1463C15.8867 17.2602 16.3471 16.7793 17.1956 16.7793Z" fill="#7141F9"/>
<g clip-path="url(#clip0_1349_6871)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.5402 0H18.0047C8.06752 0 0 8.9774 0 20.0353V40H21.7051C31.5487 40 39.5402 31.1068 39.5402 20.1531V0Z" fill="white"/>
<path opacity="0.44" d="M13.8312 16.7786V23.223C13.8312 25.0026 15.2125 26.4452 16.9163 26.4452H23.0865V27.324C23.0865 28.8431 22.2972 29.6675 20.8428 29.6675H12.9898C11.5354 29.6675 10.7461 28.8431 10.7461 27.324V19.122C10.7461 17.6029 11.5354 16.7786 12.9898 16.7786H13.8312ZM25.9847 11.4082C27.4391 11.4082 28.2284 12.2326 28.2284 13.7516V21.9537C28.2284 23.4727 27.4391 24.2971 25.9847 24.2971H25.1433V17.8526C25.1433 16.0731 23.762 14.6304 22.0582 14.6304H15.8879V13.7516C15.8879 12.2326 16.6772 11.4082 18.1317 11.4082H25.9847Z" fill="#7141F9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.1975 16.7793H21.7784C22.6268 16.7793 23.0873 17.2602 23.0873 18.1463V22.9308C23.0873 23.8169 22.6268 24.2978 21.7784 24.2978H17.1975C16.3491 24.2978 15.8887 23.8169 15.8887 22.9308V18.1463C15.8887 17.2602 16.3491 16.7793 17.1975 16.7793Z" fill="#7141F9"/>
</g>
<defs>
<clipPath id="clip0_1349_6871">
<rect width="40" height="40" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@ -1,7 +1,14 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.1" fill-rule="evenodd" clip-rule="evenodd" d="M39.5402 0H18.0047C8.06752 0 0 8.9774 0 20.0353V40H21.7051C31.5487 40 39.5402 31.1068 39.5402 20.1531V0Z" fill="#16B378"/>
<path opacity="0.44" d="M23.9718 12.2216C23.9718 11.3625 23.305 10.666 22.4824 10.666C21.6599 10.666 20.993 11.3625 20.993 12.2216V26.7401C20.993 27.5992 21.6599 28.2956 22.4824 28.2956C23.305 28.2956 23.9718 27.5992 23.9718 26.7401V12.2216Z" fill="#16B378"/>
<path d="M19.007 17.7304C19.007 16.8713 18.3402 16.1748 17.5177 16.1748C16.6951 16.1748 16.0283 16.8713 16.0283 17.7304V26.7396C16.0283 27.5987 16.6951 28.2952 17.5177 28.2952C18.3402 28.2952 19.007 27.5987 19.007 26.7396V17.7304Z" fill="#16B378"/>
<path d="M28.9362 19.9345C28.9362 19.0754 28.2694 18.3789 27.4469 18.3789C26.6243 18.3789 25.9575 19.0754 25.9575 19.9345V26.74C25.9575 27.5991 26.6243 28.2956 27.4469 28.2956C28.2694 28.2956 28.9362 27.5991 28.9362 26.74V19.9345Z" fill="#16B378"/>
<path d="M14.0427 22.1386C14.0427 21.2795 13.3759 20.583 12.5533 20.583C11.7308 20.583 11.064 21.2795 11.064 22.1386V26.7404C11.064 27.5995 11.7308 28.296 12.5533 28.296C13.3759 28.296 14.0427 27.5995 14.0427 26.7404V22.1386Z" fill="#16B378"/>
<g clip-path="url(#clip0_1349_6898)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.5402 0H18.0047C8.06752 0 0 8.9774 0 20.0353V40H21.7051C31.5487 40 39.5402 31.1068 39.5402 20.1531V0Z" fill="white"/>
<path opacity="0.44" d="M23.9729 12.9996C23.9729 12.2264 23.3061 11.5996 22.4835 11.5996C21.661 11.5996 20.9941 12.2264 20.9941 12.9996V26.0663C20.9941 26.8395 21.661 27.4663 22.4835 27.4663C23.3061 27.4663 23.9729 26.8395 23.9729 26.0663V12.9996Z" fill="#16B378"/>
<path d="M19.008 17.9576C19.008 17.1844 18.3412 16.5576 17.5187 16.5576C16.6961 16.5576 16.0293 17.1844 16.0293 17.9576V26.066C16.0293 26.8391 16.6961 27.466 17.5187 27.466C18.3412 27.466 19.008 26.8391 19.008 26.066V17.9576Z" fill="#16B378"/>
<path d="M28.9358 19.941C28.9358 19.1678 28.2689 18.541 27.4464 18.541C26.6238 18.541 25.957 19.1678 25.957 19.941V26.066C25.957 26.8392 26.6238 27.466 27.4464 27.466C28.2689 27.466 28.9358 26.8392 28.9358 26.066V19.941Z" fill="#16B378"/>
<path d="M14.0432 21.9254C14.0432 21.1522 13.3764 20.5254 12.5538 20.5254C11.7313 20.5254 11.0645 21.1522 11.0645 21.9254V26.0671C11.0645 26.8403 11.7313 27.4671 12.5538 27.4671C13.3764 27.4671 14.0432 26.8403 14.0432 26.0671V21.9254Z" fill="#16B378"/>
</g>
<defs>
<clipPath id="clip0_1349_6898">
<rect width="40" height="40" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -0,0 +1,3 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.0625 3.82104H15.8125V16.321H17.0625V3.82104ZM14.2643 10.9648C14.5785 10.7766 14.7708 10.4373 14.7708 10.071C14.7708 9.7048 14.5785 9.36546 14.2643 9.17732L5.82677 4.12551C5.50493 3.93281 5.10435 3.92804 4.77802 4.11301C4.45168 4.29798 4.25 4.64412 4.25 5.01923V15.1229C4.25 15.498 4.45168 15.8441 4.77802 16.0291C5.10435 16.214 5.50493 16.2093 5.82677 16.0166L14.2643 10.9648ZM5.29167 5.01923L13.7292 10.071L5.29167 15.1229L5.29167 5.01923Z" fill="#16B378"/>
</svg>

After

Width:  |  Height:  |  Size: 615 B

@ -0,0 +1,3 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.24081 4.84923C2.60023 4.4299 3.23153 4.38134 3.65086 4.74076L7.15086 7.74076C7.57018 8.10018 7.61875 8.73148 7.25932 9.15081C6.8999 9.57014 6.2686 9.6187 5.84928 9.25927L2.34928 6.25927C1.92995 5.89985 1.88139 5.26855 2.24081 4.84923ZM11.5 7.02081C11.137 7.02081 10.8052 7.2259 10.6429 7.55057L8.87455 11.0872L4.98709 11.6527C4.62616 11.7052 4.32631 11.958 4.2136 12.3049C4.1009 12.6518 4.19487 13.0326 4.45602 13.2872L7.28619 16.0466L6.64959 19.9369C6.59079 20.2962 6.74071 20.6578 7.03651 20.8702C7.33231 21.0825 7.72289 21.1089 8.04453 20.9382L11.5 19.1047L14.9555 20.9382C15.2772 21.1089 15.6678 21.0825 15.9636 20.8702C16.2594 20.6578 16.4093 20.2962 16.3505 19.9369L15.7139 16.0466L18.544 13.2872C18.8052 13.0326 18.8992 12.6518 18.7865 12.3049C18.6738 11.958 18.3739 11.7052 18.013 11.6527L14.1255 11.0872L12.3572 7.55057C12.1949 7.2259 11.863 7.02081 11.5 7.02081ZM10.365 12.3921L11.5 10.122L12.6351 12.3921C12.7753 12.6726 13.0439 12.8667 13.3543 12.9119L15.8162 13.27L14.0185 15.0227C13.7946 15.241 13.6913 15.5549 13.7418 15.8636L14.1469 18.3393L11.9492 17.1732C11.6683 17.0242 11.3318 17.0242 11.0508 17.1732L8.85317 18.3393L9.25829 15.8636C9.3088 15.5549 9.2055 15.241 8.98155 15.0227L7.18391 13.27L9.64579 12.9119C9.95613 12.8667 10.2248 12.6726 10.365 12.3921ZM19.3492 4.74076C19.7686 4.38134 20.3999 4.4299 20.7593 4.84923C21.1187 5.26855 21.0701 5.89985 20.6508 6.25927L17.1508 9.25927C16.7315 9.6187 16.1002 9.57014 15.7408 9.15081C15.3814 8.73148 15.4299 8.10018 15.8492 7.74076L19.3492 4.74076ZM22.2753 19.6316C21.9265 20.0598 21.2966 20.1241 20.8684 19.7753L18.3684 17.7386C17.9403 17.3897 17.876 16.7598 18.2248 16.3317C18.5736 15.9035 19.2035 15.8392 19.6317 16.188L22.1317 18.2247C22.5599 18.5736 22.6242 19.2035 22.2753 19.6316ZM2.13165 19.7753C1.70347 20.1241 1.07358 20.0598 0.724752 19.6316C0.375919 19.2035 0.440238 18.5736 0.868413 18.2247L3.36841 16.188C3.79659 15.8392 4.42648 15.9035 4.77531 16.3317C5.12414 16.7598 5.05982 17.3897 4.63165 17.7386L2.13165 19.7753Z" fill="#16B378"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -0,0 +1,6 @@
<svg width="39" height="35" viewBox="0 0 39 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group">
<path id="Vector (Stroke)" fill-rule="evenodd" clip-rule="evenodd" d="M4.0625 3.25C3.40666 3.25 2.875 3.78166 2.875 4.4375V30.5625C2.875 31.2183 3.40666 31.75 4.0625 31.75H34.9375C35.5933 31.75 36.125 31.2183 36.125 30.5625V4.4375C36.125 3.78166 35.5933 3.25 34.9375 3.25H4.0625ZM0.5 4.4375C0.5 2.46999 2.09499 0.875 4.0625 0.875H34.9375C36.905 0.875 38.5 2.46999 38.5 4.4375V30.5625C38.5 32.53 36.905 34.125 34.9375 34.125H4.0625C2.09499 34.125 0.5 32.53 0.5 30.5625V4.4375Z" fill="white"/>
<path id="Vector (Stroke)_2" fill-rule="evenodd" clip-rule="evenodd" d="M12.9911 8.14653C13.371 7.93797 13.8344 7.95296 14.2 8.18565L27.2625 16.4982C27.6051 16.7161 27.8125 17.094 27.8125 17.5C27.8125 17.906 27.6051 18.2839 27.2625 18.5019L14.2 26.8144C13.8344 27.047 13.371 27.062 12.9911 26.8535C12.6111 26.6449 12.375 26.2459 12.375 25.8125V9.1875C12.375 8.75409 12.6111 8.35509 12.9911 8.14653ZM14.75 11.3507V23.6493L24.4131 17.5L14.75 11.3507Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -0,0 +1,3 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 7.24105C6.5 5.69747 8.17443 4.73573 9.50774 5.51349L18.5231 10.7725C19.8461 11.5442 19.8461 13.4558 18.5231 14.2276L9.50774 19.4865C8.17443 20.2643 6.5 19.3026 6.5 17.759V7.24105ZM17.5154 12.5L8.5 7.24105V17.759L17.5154 12.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

Binary file not shown.

@ -13,22 +13,27 @@ describe('DocTutorial', function () {
let doc: DocCreationInfo;
let api: UserAPI;
let ownerSession: gu.Session;
let editorSession: gu.Session;
let viewerSession: gu.Session;
let oldEnv: EnvironmentSnapshot;
const cleanup = setupTestSuite({team: true});
const cleanup = setupTestSuite({samples: true, team: true});
before(async () => {
ownerSession = await gu.session().customTeamSite('templates').user('support').login();
doc = await ownerSession.tempDoc(cleanup, 'DocTutorial.grist', {load: false});
oldEnv = new EnvironmentSnapshot();
process.env.GRIST_UI_FEATURES = 'tutorials';
process.env.GRIST_TEMPLATE_ORG = 'templates';
process.env.GRIST_ONBOARDING_TUTORIAL_DOC_ID = doc.id;
await server.restart();
ownerSession = await gu.session().teamSite.user('support').login();
doc = await ownerSession.tempDoc(cleanup, 'DocTutorial.grist');
api = ownerSession.createHomeApi();
await api.updateDoc(doc.id, {type: 'tutorial'});
await api.updateDocPermissions(doc.id, {users: {
'anon@getgrist.com': 'viewers',
'everyone@getgrist.com': 'viewers',
[gu.translateUser('user1').email]: 'editors',
}});
});
@ -43,20 +48,25 @@ describe('DocTutorial', function () {
});
it('shows a tutorial card', async function() {
await viewerSession.loadRelPath('/');
await viewerSession.loadDocMenu('/');
assert.isTrue(await driver.find('.test-onboarding-tutorial-card').isDisplayed());
assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '0%');
});
it('can dismiss tutorial card', async function() {
await driver.find('.test-onboarding-dismiss-cards').click();
assert.isFalse(await driver.find('.test-onboarding-tutorial-card').isPresent());
await driver.navigate().refresh();
await gu.waitForDocMenuToLoad();
await gu.skipWelcomeQuestions();
assert.isFalse(await driver.find('.test-onboarding-tutorial-card').isPresent());
});
assert.isTrue(await driver.find('.test-tutorial-card-content').isDisplayed());
// Can dismiss it.
await driver.find('.test-tutorial-card-close').click();
assert.isFalse((await driver.findAll('.test-tutorial-card-content')).length > 0);
// When dismissed, we can see link in the menu.
it('shows a link to tutorial', async function() {
assert.isTrue(await driver.find('.test-dm-basic-tutorial').isDisplayed());
});
it('redirects user to log in', async function() {
await viewerSession.loadDoc(`/doc/${doc.id}`, {wait: false});
await driver.find('.test-dm-basic-tutorial').click();
await gu.checkLoginPage();
});
});
@ -65,41 +75,24 @@ describe('DocTutorial', function () {
let forkUrl: string;
before(async () => {
ownerSession = await gu.session().teamSite.user('user1').login({showTips: true});
editorSession = await gu.session().customTeamSite('templates').user('user1').login({showTips: true});
await editorSession.loadDocMenu('/');
await driver.executeScript('resetDismissedPopups();');
await gu.waitForServer();
});
afterEach(() => gu.checkForErrors());
it('shows a tutorial card', async function() {
await ownerSession.loadRelPath('/');
await gu.waitForDocMenuToLoad();
await gu.skipWelcomeQuestions();
// Make sure we have clean start.
await driver.executeScript('resetDismissedPopups();');
await gu.waitForServer();
await driver.navigate().refresh();
await gu.waitForDocMenuToLoad();
// Make sure we see the card.
assert.isTrue(await driver.find('.test-tutorial-card-content').isDisplayed());
// And can dismiss it.
await driver.find('.test-tutorial-card-close').click();
assert.isFalse((await driver.findAll('.test-tutorial-card-content')).length > 0);
// When dismissed, we can see link in the menu.
assert.isTrue(await driver.find('.test-dm-basic-tutorial').isDisplayed());
// Prefs are preserved after reload.
await driver.navigate().refresh();
await gu.waitForDocMenuToLoad();
assert.isFalse((await driver.findAll('.test-tutorial-card-content')).length > 0);
assert.isTrue(await driver.find('.test-dm-basic-tutorial').isDisplayed());
assert.isTrue(await driver.find('.test-onboarding-tutorial-card').isDisplayed());
await gu.waitToPass(async () =>
assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '0%'),
2000
);
});
it('creates a fork the first time the document is opened', async function() {
await ownerSession.loadDoc(`/doc/${doc.id}`);
await driver.find('.test-dm-basic-tutorial').click();
await driver.wait(async () => {
forkUrl = await driver.getCurrentUrl();
return /~/.test(forkUrl);
@ -274,7 +267,7 @@ describe('DocTutorial', function () {
});
it('does not show the GristDocTutorial page or table to non-editors', async function() {
viewerSession = await gu.session().teamSite.user('user2').login();
viewerSession = await gu.session().customTeamSite('templates').user('user2').login();
await viewerSession.loadDoc(`/doc/${doc.id}`);
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2']);
await driver.find('.test-tools-raw').click();
@ -300,7 +293,7 @@ describe('DocTutorial', function () {
otherForkUrl = await driver.getCurrentUrl();
return /~/.test(forkUrl);
});
ownerSession = await gu.session().teamSite.user('user1').login();
editorSession = await gu.session().customTeamSite('templates').user('user1').login();
await driver.navigate().to(otherForkUrl!);
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
await driver.navigate().to(forkUrl);
@ -424,7 +417,7 @@ describe('DocTutorial', function () {
it('remembers the last slide the user had open', async function() {
await driver.find('.test-doc-tutorial-popup-slide-3').click();
// There's a 1000ms debounce in place for updates to the last slide.
// There's a 1000ms debounce in place when updating tutorial progress.
await driver.sleep(1000 + 250);
await gu.waitForServer();
await driver.navigate().refresh();
@ -446,7 +439,7 @@ describe('DocTutorial', function () {
await gu.getCell(0, 1).click();
await gu.sendKeys('Redacted', Key.ENTER);
await gu.waitForServer();
await ownerSession.loadDoc(`/doc/${doc.id}`);
await editorSession.loadDoc(`/doc/${doc.id}`);
let currentUrl: string;
await driver.wait(async () => {
currentUrl = await driver.getCurrentUrl();
@ -456,7 +449,20 @@ describe('DocTutorial', function () {
assert.deepEqual(await gu.getVisibleGridCells({cols: [0], rowNums: [1]}), ['Redacted']);
});
it('tracks completion percentage', async function() {
await driver.find('.test-doc-tutorial-popup-end-tutorial').click();
await gu.waitForServer();
await gu.waitForDocMenuToLoad();
await gu.waitToPass(async () =>
assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '15%'),
2000
);
await driver.find('.test-dm-basic-tutorial').click();
await gu.waitForDocToLoad();
});
it('skips starting or resuming a tutorial if the open mode is set to default', async function() {
ownerSession = await gu.session().customTeamSite('templates').user('support').login();
await ownerSession.loadDoc(`/doc/${doc.id}/m/default`);
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'GristDocTutorial']);
await driver.find('.test-tools-raw').click();
@ -467,11 +473,14 @@ describe('DocTutorial', function () {
});
it('can restart tutorials', async function() {
// Simulate that the tutorial has been updated since it was forked.
await api.updateDoc(doc.id, {name: 'DocTutorial V2'});
await api.applyUserActions(doc.id, [['AddTable', 'NewTable', [{id: 'A'}]]]);
// Update the tutorial as the owner.
await driver.find('.test-bc-doc').doClick();
await driver.sendKeys('DocTutorial V2', Key.ENTER);
await gu.waitForServer();
await gu.addNewTable();
// Load the fork of the tutorial.
// Switch back to the editor's fork of the tutorial.
editorSession = await gu.session().customTeamSite('templates').user('user1').login();
await driver.navigate().to(forkUrl);
await gu.waitForDocToLoad();
await driver.findWait('.test-doc-tutorial-popup', 2000);
@ -503,10 +512,13 @@ describe('DocTutorial', function () {
// Check that changes made to the tutorial since it was last started are included.
assert.equal(await driver.find('.test-doc-tutorial-popup-header').getText(),
'DocTutorial V2');
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'GristDocTutorial', 'NewTable']);
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'GristDocTutorial', 'Table1']);
});
it('allows editors to replace original', async function() {
it('allows owners to replace original', async function() {
ownerSession = await gu.session().customTeamSite('templates').user('support').login();
await ownerSession.loadDoc(`/doc/${doc.id}`);
// Make an edit to one of the tutorial slides.
await gu.openPage('GristDocTutorial');
await gu.getCell(1, 1).click();
@ -532,7 +544,7 @@ describe('DocTutorial', function () {
await gu.waitForServer();
// Switch to another user and restart the tutorial.
viewerSession = await gu.session().teamSite.user('user2').login();
viewerSession = await gu.session().customTeamSite('templates').user('user2').login();
await viewerSession.loadDoc(`/doc/${doc.id}`);
await driver.findWait('.test-doc-tutorial-popup-restart', 2000).click();
await driver.find('.test-modal-confirm').click();
@ -554,13 +566,22 @@ describe('DocTutorial', function () {
await driver.find('.test-doc-tutorial-popup-next').click();
await gu.waitForDocMenuToLoad();
assert.match(await driver.getCurrentUrl(), /o\/docs\/$/);
await gu.waitToPass(async () =>
assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '0%'),
2000
);
await ownerSession.loadDocMenu('/');
await gu.waitToPass(async () =>
assert.equal(await driver.find('.test-onboarding-tutorial-percent-complete').getText(), '100%'),
2000
);
});
});
describe('without tutorial flag set', function () {
before(async () => {
await api.updateDoc(doc.id, {type: null});
ownerSession = await gu.session().teamSite.user('user1').login();
ownerSession = await gu.session().customTeamSite('templates').user('support').login();
await ownerSession.loadDoc(`/doc/${doc.id}`);
});
@ -568,7 +589,7 @@ describe('DocTutorial', function () {
it('shows the GristDocTutorial page and table', async function() {
assert.deepEqual(await gu.getPageNames(),
['Page 1', 'Page 2', 'GristDocTutorial', 'NewTable']);
['Page 1', 'Page 2', 'GristDocTutorial', 'Table1']);
await gu.openPage('GristDocTutorial');
assert.deepEqual(
await gu.getVisibleGridCells({cols: [1, 2], rowNums: [1]}),

@ -33,6 +33,7 @@ describe('Features', function () {
it('can be disabled with the GRIST_HIDE_UI_ELEMENTS env variable', async function () {
process.env.GRIST_UI_FEATURES = 'helpCenter,tutorials';
process.env.GRIST_HIDE_UI_ELEMENTS = 'templates';
process.env.GRIST_ONBOARDING_TUTORIAL_DOC_ID = 'tutorialDocId';
await server.restart();
await session.loadDocMenu('/');
assert.isTrue(await driver.find('.test-left-feedback').isDisplayed());

@ -52,8 +52,8 @@ describe('HomeIntro', function() {
freshAccount: true,
});
// Open doc-menu and dismiss the welcome questions popup
await session.loadDocMenu('/', 'skipWelcomeQuestions');
// Open doc-menu and skip onboarding
await session.loadDocMenu('/', 'skipOnboarding');
// Reload the doc-menu and dismiss the coaching call popup
await session.loadDocMenu('/');
@ -83,7 +83,7 @@ describe('HomeIntro', function() {
await session.resetSite();
// Open doc-menu
await session.loadDocMenu('/', 'skipWelcomeQuestions');
await session.loadDocMenu('/');
// Check message specific to logged-in user and an empty team site.
assert.match(await driver.find('.test-welcome-title').getText(), new RegExp(`Welcome.* ${session.orgName}`));

@ -826,8 +826,23 @@ export async function loadDoc(relPath: string, wait: boolean = true): Promise<vo
if (wait) { await waitForDocToLoad(); }
}
export async function loadDocMenu(relPath: string, wait: boolean = true): Promise<void> {
/**
* Load a DocMenu on a site.
*
* If loading for a potentially first-time user, you may give 'skipOnboarding' for second
* argument to skip the onboarding flow, if it gets shown.
*/
export async function loadDocMenu(relPath: string, wait: boolean|'skipOnboarding' = true): Promise<void> {
await driver.get(`${server.getHost()}${relPath}`);
if (wait === 'skipOnboarding') {
const first = await Promise.race([
driver.findWait('.test-onboarding-page', 2000),
driver.findWait('.test-dm-doclist', 2000),
]);
if (await first.matches('.test-onboarding-page')) {
await skipOnboarding();
}
}
if (wait) { await waitForDocMenuToLoad(); }
}
@ -2105,7 +2120,6 @@ export class Session {
freshAccount?: boolean,
isFirstLogin?: boolean,
showTips?: boolean,
skipTutorial?: boolean, // By default true
userName?: string,
email?: string,
retainExistingLogin?: boolean}) {
@ -2129,11 +2143,6 @@ export class Session {
}
await server.simulateLogin(this.settings.name, this.settings.email, this.settings.orgDomain,
{isFirstLogin: false, cacheCredentials: true, ...options});
if (options?.skipTutorial ?? true) {
await dismissTutorialCard();
}
return this;
}
@ -2172,17 +2181,20 @@ export class Session {
}
// Load a DocMenu on a site.
// If loading for a potentially first-time user, you may give 'skipWelcomeQuestions' for second
// argument to dismiss the popup with welcome questions, if it gets shown.
public async loadDocMenu(relPath: string, wait: boolean|'skipWelcomeQuestions' = true) {
// If loading for a potentially first-time user, you may give 'skipOnboarding' for second
// argument to skip the onboarding flow, if it gets shown.
public async loadDocMenu(relPath: string, wait: boolean|'skipOnboarding' = true) {
await this.loadRelPath(relPath);
if (wait) { await waitForDocMenuToLoad(); }
if (wait === 'skipWelcomeQuestions') {
// When waitForDocMenuToLoad() returns, welcome questions should also render, so that we
// don't need to wait extra for them.
await skipWelcomeQuestions();
if (wait === 'skipOnboarding') {
const first = await Promise.race([
driver.findWait('.test-onboarding-page', 2000),
driver.findWait('.test-dm-doclist', 2000),
]);
if (await first.matches('.test-onboarding-page')) {
await skipOnboarding();
}
}
if (wait) { await waitForDocMenuToLoad(); }
}
public async loadRelPath(relPath: string) {
@ -3151,21 +3163,6 @@ export async function getFilterMenuState(): Promise<FilterMenuValue[]> {
}));
}
/**
* Dismisses any tutorial card that might be active.
*/
export async function dismissTutorialCard() {
// If there is something in our way, we can't do it.
if (await driver.find('.test-welcome-questions').isPresent()) {
return;
}
if (await driver.find('.test-tutorial-card-close').isPresent()) {
if (await driver.find('.test-tutorial-card-close').isDisplayed()) {
await driver.find('.test-tutorial-card-close').click();
}
}
}
/**
* Dismisses coaching call if needed.
*/
@ -3370,11 +3367,14 @@ export async function setRangeFilterBound(minMax: 'min'|'max', value: string|{re
}
}
export async function skipWelcomeQuestions() {
if (await driver.find('.test-welcome-questions').isPresent()) {
await driver.sendKeys(Key.ESCAPE);
assert.equal(await driver.find('.test-welcome-questions').isPresent(), false);
}
/**
* Skips the onboarding page that's shown to users on their first visit to the
* doc menu.
*/
export async function skipOnboarding() {
await driver.findWait('.test-onboarding-page', 2000);
await waitForServer();
await driver.navigate().refresh();
}
/**

Loading…
Cancel
Save