mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Update onboarding flow
Summary: A new onboarding page is now shown to all new users visiting the doc menu for the first time. Tutorial cards on the doc menu have been replaced with a new version that tracks completion progress, alongside a new card that opens the orientation video. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4296
This commit is contained in:
@@ -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),
|
||||
/* first line */
|
||||
dom.create(buildOnboardingCards, {homeModel: home}),
|
||||
/* hbox */
|
||||
css.docListContent(
|
||||
/* left column - grow 1 */
|
||||
css.docMenu(
|
||||
attachAddNewTip(home),
|
||||
|
||||
dom.maybe(!home.app.currentFeatures?.workspaces, () => [
|
||||
css.docListHeader(t("This service is not available right now")),
|
||||
dom('span', t("(The organization needs a paid plan)")),
|
||||
]),
|
||||
dom.maybe(!home.app.currentFeatures?.workspaces, () => [
|
||||
css.docListHeader(t("This service is not available right now")),
|
||||
dom('span', t("(The organization needs a paid plan)")),
|
||||
]),
|
||||
|
||||
// currentWS and showIntro observables change together. We capture both in one domComputed call.
|
||||
dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
|
||||
(use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
|
||||
([page, workspace, showIntro]) => {
|
||||
const viewSettings: ViewSettings =
|
||||
page === 'trash' ? makeLocalViewSettings(home, 'trash') :
|
||||
page === 'templates' ? makeLocalViewSettings(home, 'templates') :
|
||||
workspace ? makeLocalViewSettings(home, workspace.id) :
|
||||
home;
|
||||
return [
|
||||
buildPrefs(
|
||||
viewSettings,
|
||||
// Hide the sort and view options when showing the intro.
|
||||
{hideSort: showIntro, hideView: showIntro && page === 'all'},
|
||||
['all', 'workspace'].includes(page)
|
||||
? upgradeButton.showUpgradeButton(css.upgradeButton.cls(''))
|
||||
: null,
|
||||
),
|
||||
|
||||
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded.
|
||||
// TODO: this is shown on all pages, but there is a hack in currentWSPinnedDocs that
|
||||
// removes all pinned docs when on trash page.
|
||||
dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [
|
||||
css.docListHeader(css.pinnedDocsIcon('PinBig'), t("Pinned Documents")),
|
||||
createPinnedDocs(home, home.currentWSPinnedDocs),
|
||||
]),
|
||||
|
||||
// Build the featured templates dom if on the Examples & Templates page.
|
||||
dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [
|
||||
css.featuredTemplatesHeader(
|
||||
css.featuredTemplatesIcon('Idea'),
|
||||
t("Featured"),
|
||||
testId('featured-templates-header')
|
||||
// currentWS and showIntro observables change together. We capture both in one domComputed call.
|
||||
dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
|
||||
(use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
|
||||
([page, workspace, showIntro]) => {
|
||||
const viewSettings: ViewSettings =
|
||||
page === 'trash' ? makeLocalViewSettings(home, 'trash') :
|
||||
page === 'templates' ? makeLocalViewSettings(home, 'templates') :
|
||||
workspace ? makeLocalViewSettings(home, workspace.id) :
|
||||
home;
|
||||
return [
|
||||
buildPrefs(
|
||||
viewSettings,
|
||||
// Hide the sort and view options when showing the intro.
|
||||
{hideSort: showIntro, hideView: showIntro && page === 'all'},
|
||||
['all', 'workspace'].includes(page)
|
||||
? upgradeButton.showUpgradeButton(css.upgradeButton.cls(''))
|
||||
: null,
|
||||
),
|
||||
createPinnedDocs(home, home.featuredTemplates, true),
|
||||
]),
|
||||
|
||||
dom.maybe(home.available, () => [
|
||||
buildOtherSites(home),
|
||||
(showIntro && page === 'all' ?
|
||||
null :
|
||||
css.docListHeader(
|
||||
(
|
||||
page === 'all' ? t("All Documents") :
|
||||
page === 'templates' ?
|
||||
dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
|
||||
hasFeaturedTemplates ? t("More Examples and Templates") : t("Examples and Templates")
|
||||
) :
|
||||
page === 'trash' ? t("Trash") :
|
||||
workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
|
||||
),
|
||||
testId('doc-header'),
|
||||
)
|
||||
),
|
||||
(
|
||||
(page === 'all') ?
|
||||
dom('div',
|
||||
showIntro ? buildHomeIntro(home) : null,
|
||||
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
|
||||
shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
|
||||
) :
|
||||
(page === 'trash') ?
|
||||
dom('div',
|
||||
css.docBlock(t("Documents stay in Trash for 30 days, after which they get deleted permanently.")),
|
||||
dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
|
||||
css.docBlock(t("Trash is empty."))
|
||||
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded.
|
||||
// TODO: this is shown on all pages, but there is a hack in currentWSPinnedDocs that
|
||||
// removes all pinned docs when on trash page.
|
||||
dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [
|
||||
css.docListHeader(css.pinnedDocsIcon('PinBig'), t("Pinned Documents")),
|
||||
createPinnedDocs(home, home.currentWSPinnedDocs),
|
||||
]),
|
||||
|
||||
// Build the featured templates dom if on the Examples & Templates page.
|
||||
dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [
|
||||
css.featuredTemplatesHeader(
|
||||
css.featuredTemplatesIcon('Idea'),
|
||||
t("Featured"),
|
||||
testId('featured-templates-header')
|
||||
),
|
||||
createPinnedDocs(home, home.featuredTemplates, true),
|
||||
]),
|
||||
|
||||
dom.maybe(home.available, () => [
|
||||
buildOtherSites(home),
|
||||
(showIntro && page === 'all' ?
|
||||
null :
|
||||
css.docListHeader(
|
||||
(
|
||||
page === 'all' ? t("All Documents") :
|
||||
page === 'templates' ?
|
||||
dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
|
||||
hasFeaturedTemplates ? t("More Examples and Templates") : t("Examples and Templates")
|
||||
) :
|
||||
page === 'trash' ? t("Trash") :
|
||||
workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
|
||||
),
|
||||
buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
|
||||
) :
|
||||
(page === 'templates') ?
|
||||
dom('div',
|
||||
buildAllTemplates(home, home.templateWorkspaces, viewSettings)
|
||||
) :
|
||||
workspace && !workspace.isSupportWorkspace && workspace.docs?.length ?
|
||||
css.docBlock(
|
||||
buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings),
|
||||
testId('doc-block')
|
||||
testId('doc-header'),
|
||||
)
|
||||
),
|
||||
(
|
||||
(page === 'all') ?
|
||||
dom('div',
|
||||
showIntro ? buildHomeIntro(home) : null,
|
||||
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
|
||||
shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
|
||||
) :
|
||||
workspace && !workspace.isSupportWorkspace && workspace.docs?.length === 0 ?
|
||||
buildWorkspaceIntro(home) :
|
||||
css.docBlock(t("Workspace not found"))
|
||||
)
|
||||
]),
|
||||
(page === 'trash') ?
|
||||
dom('div',
|
||||
css.docBlock(t("Documents stay in Trash for 30 days, after which they get deleted permanently.")),
|
||||
dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
|
||||
css.docBlock(t("Trash is empty."))
|
||||
),
|
||||
buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
|
||||
) :
|
||||
(page === 'templates') ?
|
||||
dom('div',
|
||||
buildAllTemplates(home, home.templateWorkspaces, viewSettings)
|
||||
) :
|
||||
workspace && !workspace.isSupportWorkspace && workspace.docs?.length ?
|
||||
css.docBlock(
|
||||
buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings),
|
||||
testId('doc-block')
|
||||
) :
|
||||
workspace && !workspace.isSupportWorkspace && workspace.docs?.length === 0 ?
|
||||
buildWorkspaceIntro(home) :
|
||||
css.docBlock(t("Workspace not found"))
|
||||
)
|
||||
]),
|
||||
];
|
||||
}),
|
||||
testId('doclist')
|
||||
),
|
||||
dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
|
||||
() => {
|
||||
// TODO: These don't currently clash (grist-core stubs the upgradeButton), but a way to
|
||||
// manage card popups will be needed if more are added later.
|
||||
return [
|
||||
upgradeButton.showUpgradeCard(css.upgradeCard.cls('')),
|
||||
home.app.supportGristNudge.buildNudgeCard(),
|
||||
];
|
||||
}),
|
||||
testId('doclist')
|
||||
),
|
||||
dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
|
||||
() => {
|
||||
// TODO: These don't currently clash (grist-core stubs the upgradeButton), but a way to
|
||||
// manage card popups will be needed if more are added later.
|
||||
return [
|
||||
upgradeButton.showUpgradeCard(css.upgradeCard.cls('')),
|
||||
home.app.supportGristNudge.buildNudgeCard(),
|
||||
];
|
||||
}),
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
function buildAllDocsBlock(
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
|
||||
232
app/client/ui/OnboardingCards.ts
Normal file
232
app/client/ui/OnboardingCards.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {HomeModel} from 'app/client/models/HomeModel';
|
||||
import {openVideoTour} from 'app/client/ui/OpenVideoTour';
|
||||
import {bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
|
||||
import {colors, theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {isFeatureEnabled} from 'app/common/gristUrls';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {Computed, dom, IDisposableOwner, makeTestId, styled, subscribeElem} from 'grainjs';
|
||||
|
||||
interface BuildOnboardingCardsOptions {
|
||||
homeModel: HomeModel;
|
||||
}
|
||||
|
||||
const t = makeT('OnboardingCards');
|
||||
|
||||
const testId = makeTestId('test-onboarding-');
|
||||
|
||||
export function buildOnboardingCards(
|
||||
owner: IDisposableOwner,
|
||||
{homeModel}: BuildOnboardingCardsOptions
|
||||
) {
|
||||
const {templateOrg, onboardingTutorialDocId} = getGristConfig();
|
||||
if (!isFeatureEnabled('tutorials') || !templateOrg || !onboardingTutorialDocId) { return null; }
|
||||
|
||||
const percentComplete = Computed.create(owner, (use) => {
|
||||
if (!homeModel.app.currentValidUser) { return 0; }
|
||||
|
||||
const tutorial = use(homeModel.onboardingTutorial);
|
||||
if (!tutorial) { return undefined; }
|
||||
|
||||
return tutorial.forks?.[0]?.options?.tutorial?.percentComplete ?? 0;
|
||||
});
|
||||
|
||||
const shouldShowCards = Computed.create(owner, (use) =>
|
||||
!use(homeModel.app.dismissedPopups).includes('onboardingCards'));
|
||||
|
||||
let videoPlayButtonElement: HTMLElement;
|
||||
|
||||
return dom.maybe(shouldShowCards, () =>
|
||||
cssOnboardingCards(
|
||||
cssTutorialCard(
|
||||
cssDismissCardsButton(
|
||||
icon('CrossBig'),
|
||||
dom.on('click', () => homeModel.app.dismissPopup('onboardingCards', true)),
|
||||
testId('dismiss-cards'),
|
||||
),
|
||||
cssTutorialCardHeader(
|
||||
t('Complete our basics tutorial'),
|
||||
),
|
||||
cssTutorialCardSubHeader(
|
||||
t('Learn the basic of reference columns, linked widgets, column types, & cards.')
|
||||
),
|
||||
cssTutorialCardBody(
|
||||
cssTutorialProgress(
|
||||
cssTutorialProgressText(
|
||||
cssProgressPercentage(
|
||||
dom.domComputed(percentComplete, (percent) => percent !== undefined ? `${percent}%` : null),
|
||||
testId('tutorial-percent-complete'),
|
||||
),
|
||||
cssStarIcon('Star'),
|
||||
),
|
||||
cssTutorialProgressBar(
|
||||
(elem) => subscribeElem(elem, percentComplete, (val) => {
|
||||
elem.style.setProperty('--percent-complete', String(val ?? 0));
|
||||
})
|
||||
),
|
||||
),
|
||||
bigPrimaryButtonLink(
|
||||
t('Complete the tutorial'),
|
||||
urlState().setLinkUrl({org: templateOrg, doc: onboardingTutorialDocId}),
|
||||
),
|
||||
),
|
||||
testId('tutorial-card'),
|
||||
),
|
||||
cssVideoCard(
|
||||
cssVideoThumbnail(
|
||||
cssVideoThumbnailSpacer(),
|
||||
videoPlayButtonElement = cssVideoPlayButton(
|
||||
cssPlayIcon('VideoPlay2'),
|
||||
),
|
||||
cssVideoThumbnailText(t('3 minute video tour')),
|
||||
),
|
||||
dom.on('click', () => openVideoTour(videoPlayButtonElement)),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const cssOnboardingCards = styled('div', `
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, max-content));
|
||||
gap: 24px;
|
||||
margin: 24px 0;
|
||||
`);
|
||||
|
||||
const cssTutorialCard = styled('div', `
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
color: ${theme.announcementPopupFg};
|
||||
background-color: ${theme.announcementPopupBg};
|
||||
padding: 16px 24px;
|
||||
`);
|
||||
|
||||
const cssTutorialCardHeader = styled('div', `
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
`);
|
||||
|
||||
const cssDismissCardsButton = styled('div', `
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
--icon-color: ${theme.popupCloseButtonFg};
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssTutorialCardSubHeader = styled('div', `
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
margin: 8px 0;
|
||||
`);
|
||||
|
||||
const cssTutorialCardBody = styled('div', `
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
margin: 16px 0;
|
||||
align-items: end;
|
||||
`);
|
||||
|
||||
const cssTutorialProgress = styled('div', `
|
||||
flex: auto;
|
||||
min-width: 120px;
|
||||
`);
|
||||
|
||||
const cssTutorialProgressText = styled('div', `
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`);
|
||||
|
||||
const cssProgressPercentage = styled('div', `
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
`);
|
||||
|
||||
const cssStarIcon = styled(icon, `
|
||||
--icon-color: ${theme.accentIcon};
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`);
|
||||
|
||||
const cssTutorialProgressBar = styled('div', `
|
||||
margin-top: 4px;
|
||||
height: 10px;
|
||||
border-radius: 8px;
|
||||
background: ${theme.mainPanelBg};
|
||||
--percent-complete: 0;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
border-radius: 8px;
|
||||
background: ${theme.progressBarFg};
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: calc((var(--percent-complete) / 100) * 100%);
|
||||
}
|
||||
`);
|
||||
|
||||
const cssVideoCard = styled('div', `
|
||||
width: 220px;
|
||||
height: 158px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
`);
|
||||
|
||||
const cssVideoThumbnail = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 36px 32px;
|
||||
background-image: url("img/youtube-screenshot.png");
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
background-blend-mode: multiply;
|
||||
background-size: cover;
|
||||
transform: scale(1.2);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`);
|
||||
|
||||
const cssVideoThumbnailSpacer = styled('div', ``);
|
||||
|
||||
const cssVideoPlayButton = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: ${theme.controlPrimaryBg};
|
||||
border-radius: 50%;
|
||||
|
||||
.${cssVideoThumbnail.className}:hover & {
|
||||
background-color: ${theme.controlPrimaryHoverBg};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPlayIcon = styled(icon, `
|
||||
--icon-color: ${theme.controlPrimaryFg};
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`);
|
||||
|
||||
const cssVideoThumbnailText = styled('div', `
|
||||
color: ${colors.light};
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
`);
|
||||
747
app/client/ui/OnboardingPage.ts
Normal file
747
app/client/ui/OnboardingPage.ts
Normal file
@@ -0,0 +1,747 @@
|
||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {logError} from 'app/client/models/errors';
|
||||
import {getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {getUserPrefObs} from 'app/client/models/UserPrefs';
|
||||
import {textInput} from 'app/client/ui/inputs';
|
||||
import {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer';
|
||||
import {bigBasicButton, bigPrimaryButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
|
||||
import {colors, mediaMedium, mediaXSmall, theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {modal} from 'app/client/ui2018/modals';
|
||||
import {BaseAPI} from 'app/common/BaseAPI';
|
||||
import {getPageTitleSuffix, ONBOARDING_VIDEO_YOUTUBE_EMBED_ID} from 'app/common/gristUrls';
|
||||
import {UserPrefs} from 'app/common/Prefs';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {
|
||||
Computed,
|
||||
Disposable,
|
||||
dom,
|
||||
DomContents,
|
||||
IDisposableOwner,
|
||||
input,
|
||||
makeTestId,
|
||||
Observable,
|
||||
styled,
|
||||
subscribeElem,
|
||||
} from 'grainjs';
|
||||
|
||||
const t = makeT('OnboardingPage');
|
||||
|
||||
const testId = makeTestId('test-onboarding-');
|
||||
|
||||
const choices: Array<{icon: IconName, color: string, textKey: string}> = [
|
||||
{icon: 'UseProduct', color: `${colors.lightGreen}`, textKey: 'Product Development' },
|
||||
{icon: 'UseFinance', color: '#0075A2', textKey: 'Finance & Accounting'},
|
||||
{icon: 'UseMedia', color: '#F7B32B', textKey: 'Media Production' },
|
||||
{icon: 'UseMonitor', color: '#F2545B', textKey: 'IT & Technology' },
|
||||
{icon: 'UseChart', color: '#7141F9', textKey: 'Marketing' },
|
||||
{icon: 'UseScience', color: '#231942', textKey: 'Research' },
|
||||
{icon: 'UseSales', color: '#885A5A', textKey: 'Sales' },
|
||||
{icon: 'UseEducate', color: '#4A5899', textKey: 'Education' },
|
||||
{icon: 'UseHr', color: '#688047', textKey: 'HR & Management' },
|
||||
{icon: 'UseOther', color: '#929299', textKey: 'Other' },
|
||||
];
|
||||
|
||||
export function shouldShowOnboardingPage(userPrefsObs: Observable<UserPrefs>): boolean {
|
||||
return Boolean(getGristConfig().survey && userPrefsObs.get()?.showNewUserQuestions);
|
||||
}
|
||||
|
||||
type IncrementStep = (delta?: 1 | -1) => void;
|
||||
|
||||
interface Step {
|
||||
state?: QuestionsState | VideoState;
|
||||
buildDom(): DomContents;
|
||||
onNavigateAway?(): void;
|
||||
}
|
||||
|
||||
interface QuestionsState {
|
||||
organization: Observable<string>;
|
||||
role: Observable<string>;
|
||||
useCases: Array<Observable<boolean>>;
|
||||
useOther: Observable<string>;
|
||||
}
|
||||
|
||||
interface VideoState {
|
||||
watched: Observable<boolean>;
|
||||
}
|
||||
|
||||
export class OnboardingPage extends Disposable {
|
||||
private _steps: Array<Step>;
|
||||
private _stepIndex: Observable<number> = Observable.create(this, 0);
|
||||
|
||||
constructor(private _appModel: AppModel) {
|
||||
super();
|
||||
|
||||
this.autoDispose(this._stepIndex.addListener((_, prevIndex) => {
|
||||
this._steps[prevIndex].onNavigateAway?.();
|
||||
}));
|
||||
|
||||
const incrementStep: IncrementStep = (delta: -1 | 1 = 1) => {
|
||||
this._stepIndex.set(this._stepIndex.get() + delta);
|
||||
};
|
||||
|
||||
this._steps = [
|
||||
{
|
||||
state: {
|
||||
organization: Observable.create(this, ''),
|
||||
role: Observable.create(this, ''),
|
||||
useCases: choices.map(() => Observable.create(this, false)),
|
||||
useOther: Observable.create(this, ''),
|
||||
},
|
||||
buildDom() { return dom.create(buildQuestions, incrementStep, this.state as QuestionsState); },
|
||||
onNavigateAway() { saveQuestions(this.state as QuestionsState); },
|
||||
},
|
||||
{
|
||||
state: {
|
||||
watched: Observable.create(this, false),
|
||||
},
|
||||
buildDom() { return dom.create(buildVideo, incrementStep, this.state as VideoState); },
|
||||
},
|
||||
{
|
||||
buildDom() { return dom.create(buildTutorial, incrementStep); },
|
||||
},
|
||||
];
|
||||
|
||||
document.title = `Welcome${getPageTitleSuffix(getGristConfig())}`;
|
||||
|
||||
getUserPrefObs(this._appModel.userPrefsObs, 'showNewUserQuestions').set(undefined);
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return cssPageContainer(
|
||||
cssOnboardingPage(
|
||||
cssSidebar(
|
||||
cssSidebarContent(
|
||||
cssSidebarHeading1(t('Welcome')),
|
||||
cssSidebarHeading2(this._appModel.currentUser!.name + '!'),
|
||||
testId('sidebar'),
|
||||
),
|
||||
cssGetStarted(
|
||||
cssGetStartedImg({src: 'img/get-started.png'}),
|
||||
),
|
||||
),
|
||||
cssMainPanel(
|
||||
buildStepper(this._steps, this._stepIndex),
|
||||
dom.domComputed(this._stepIndex, index => {
|
||||
return this._steps[index].buildDom();
|
||||
}),
|
||||
),
|
||||
testId('page'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildStepper(steps: Step[], stepIndex: Observable<number>) {
|
||||
return cssStepper(
|
||||
steps.map((_, i) =>
|
||||
cssStep(
|
||||
cssStepCircle(
|
||||
cssStepCircle.cls('-done', use => (i < use(stepIndex))),
|
||||
dom.domComputed(use => i < use(stepIndex), (done) => done ? icon('Tick') : String(i + 1)),
|
||||
cssStepCircle.cls('-current', use => (i === use(stepIndex))),
|
||||
dom.on('click', () => { stepIndex.set(i); }),
|
||||
testId(`step-${i + 1}`)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function saveQuestions(state: QuestionsState) {
|
||||
const {organization, role, useCases, useOther} = state;
|
||||
if (!organization.get() && !role.get() && !useCases.map(useCase => useCase.get()).includes(true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const org_name = organization.get();
|
||||
const org_role = role.get();
|
||||
const use_cases = choices.filter((c, i) => useCases[i].get()).map(c => c.textKey);
|
||||
const use_other = use_cases.includes('Other') ? useOther.get() : '';
|
||||
const submitUrl = new URL(window.location.href);
|
||||
submitUrl.pathname = '/welcome/info';
|
||||
BaseAPI.request(submitUrl.href, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({org_name, org_role, use_cases, use_other})
|
||||
}).catch((e) => logError(e));
|
||||
}
|
||||
|
||||
function buildQuestions(owner: IDisposableOwner, incrementStep: IncrementStep, state: QuestionsState) {
|
||||
const {organization, role, useCases, useOther} = state;
|
||||
const isFilled = Computed.create(owner, (use) => {
|
||||
return Boolean(use(organization) || use(role) || useCases.map(useCase => use(useCase)).includes(true));
|
||||
});
|
||||
|
||||
return cssQuestions(
|
||||
cssHeading(t("Tell us who you are")),
|
||||
cssQuestion(
|
||||
cssFieldHeading(t('What organization are you with?')),
|
||||
cssInput(
|
||||
organization,
|
||||
{type: 'text', placeholder: t('Your organization')},
|
||||
testId('questions-organization'),
|
||||
),
|
||||
),
|
||||
cssQuestion(
|
||||
cssFieldHeading(t('What is your role?')),
|
||||
cssInput(
|
||||
role,
|
||||
{type: 'text', placeholder: t('Your role')},
|
||||
testId('questions-role'),
|
||||
),
|
||||
),
|
||||
cssQuestion(
|
||||
cssFieldHeading(t("What brings you to Grist (you can select multiple)?")),
|
||||
cssUseCases(
|
||||
choices.map((item, i) => cssUseCase(
|
||||
cssUseCaseIcon(icon(item.icon)),
|
||||
cssUseCase.cls('-selected', useCases[i]),
|
||||
dom.on('click', () => useCases[i].set(!useCases[i].get())),
|
||||
(item.icon !== 'UseOther' ?
|
||||
t(item.textKey) :
|
||||
[
|
||||
cssOtherLabel(t(item.textKey)),
|
||||
cssOtherInput(useOther, {}, {type: 'text', placeholder: t("Type here")},
|
||||
// The following subscribes to changes to selection observable, and focuses the input when
|
||||
// this item is selected.
|
||||
(elem) => subscribeElem(elem, useCases[i], val => val && setTimeout(() => elem.focus(), 0)),
|
||||
// It's annoying if clicking into the input toggles selection; better to turn that
|
||||
// off (user can click icon to deselect).
|
||||
dom.on('click', ev => ev.stopPropagation()),
|
||||
// Similarly, ignore Enter/Escape in "Other" textbox, so that they don't submit/close the form.
|
||||
dom.onKeyDown({
|
||||
Enter: (ev, elem) => elem.blur(),
|
||||
Escape: (ev, elem) => elem.blur(),
|
||||
}),
|
||||
)
|
||||
]
|
||||
),
|
||||
testId('questions-use-case'),
|
||||
)),
|
||||
),
|
||||
),
|
||||
cssContinue(
|
||||
bigPrimaryButton(
|
||||
t('Next step'),
|
||||
dom.show(isFilled),
|
||||
dom.on('click', () => incrementStep()),
|
||||
testId('next-step'),
|
||||
),
|
||||
bigBasicButton(
|
||||
t('Skip step'),
|
||||
dom.hide(isFilled),
|
||||
dom.on('click', () => incrementStep()),
|
||||
testId('skip-step'),
|
||||
),
|
||||
),
|
||||
testId('questions'),
|
||||
);
|
||||
}
|
||||
|
||||
function buildVideo(_owner: IDisposableOwner, incrementStep: IncrementStep, state: VideoState) {
|
||||
const {watched} = state;
|
||||
|
||||
function onPlay() {
|
||||
watched.set(true);
|
||||
|
||||
return modal((ctl, modalOwner) => {
|
||||
const youtubePlayer = YouTubePlayer.create(modalOwner,
|
||||
ONBOARDING_VIDEO_YOUTUBE_EMBED_ID,
|
||||
{
|
||||
onPlayerReady: (player) => player.playVideo(),
|
||||
onPlayerStateChange(_player, {data}) {
|
||||
if (data !== PlayerState.Ended) { return; }
|
||||
|
||||
ctl.close();
|
||||
},
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
origin: getMainOrgUrl(),
|
||||
},
|
||||
cssYouTubePlayer.cls(''),
|
||||
);
|
||||
|
||||
return [
|
||||
dom.on('click', () => ctl.close()),
|
||||
elem => { FocusLayer.create(modalOwner, {defaultFocusElem: elem, pauseMousetrap: true}); },
|
||||
dom.onKeyDown({
|
||||
Escape: () => ctl.close(),
|
||||
' ': () => youtubePlayer.playPause(),
|
||||
}),
|
||||
cssModalHeader(
|
||||
cssModalCloseButton(
|
||||
cssCloseIcon('CrossBig'),
|
||||
),
|
||||
),
|
||||
cssModalBody(
|
||||
cssVideoPlayer(
|
||||
dom.on('click', (ev) => ev.stopPropagation()),
|
||||
youtubePlayer.buildDom(),
|
||||
testId('video-player'),
|
||||
),
|
||||
cssModalButtons(
|
||||
bigPrimaryButton(
|
||||
t('Next step'),
|
||||
dom.on('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ctl.close();
|
||||
incrementStep();
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
cssVideoPlayerModal.cls(''),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
return dom('div',
|
||||
cssHeading(t('Discover Grist in 3 minutes')),
|
||||
cssScreenshot(
|
||||
dom.on('click', onPlay),
|
||||
dom('div',
|
||||
cssScreenshotImg({src: 'img/youtube-screenshot.png'}),
|
||||
cssActionOverlay(
|
||||
cssAction(
|
||||
cssRoundButton(cssVideoPlayIcon('VideoPlay')),
|
||||
),
|
||||
),
|
||||
),
|
||||
testId('video-thumbnail'),
|
||||
),
|
||||
cssContinue(
|
||||
cssBackButton(
|
||||
t('Back'),
|
||||
dom.on('click', () => incrementStep(-1)),
|
||||
testId('back'),
|
||||
),
|
||||
bigPrimaryButton(
|
||||
t('Next step'),
|
||||
dom.show(watched),
|
||||
dom.on('click', () => incrementStep()),
|
||||
testId('next-step'),
|
||||
),
|
||||
bigBasicButton(
|
||||
t('Skip step'),
|
||||
dom.hide(watched),
|
||||
dom.on('click', () => incrementStep()),
|
||||
testId('skip-step'),
|
||||
),
|
||||
),
|
||||
testId('video'),
|
||||
);
|
||||
}
|
||||
|
||||
function buildTutorial(_owner: IDisposableOwner, incrementStep: IncrementStep) {
|
||||
const {templateOrg, onboardingTutorialDocId} = getGristConfig();
|
||||
return dom('div',
|
||||
cssHeading(
|
||||
t('Go hands-on with the Grist Basics tutorial'),
|
||||
cssSubHeading(
|
||||
t("Grist may look like a spreadsheet, but it doesn't always "
|
||||
+ "act like one. Discover what makes Grist different."
|
||||
),
|
||||
),
|
||||
),
|
||||
cssTutorial(
|
||||
cssScreenshot(
|
||||
dom.on('click', () => urlState().pushUrl({org: templateOrg!, doc: onboardingTutorialDocId})),
|
||||
cssTutorialScreenshotImg({src: 'img/tutorial-screenshot.png'}),
|
||||
cssTutorialOverlay(
|
||||
cssAction(
|
||||
cssTutorialButton(t('Go to the tutorial!')),
|
||||
),
|
||||
),
|
||||
testId('tutorial-thumbnail'),
|
||||
),
|
||||
),
|
||||
cssContinue(
|
||||
cssBackButton(
|
||||
t('Back'),
|
||||
dom.on('click', () => incrementStep(-1)),
|
||||
testId('back'),
|
||||
),
|
||||
bigBasicButton(
|
||||
t('Skip tutorial'),
|
||||
dom.on('click', () => window.location.href = urlState().makeUrl(urlState().state.get())),
|
||||
testId('skip-tutorial'),
|
||||
),
|
||||
),
|
||||
testId('tutorial'),
|
||||
);
|
||||
}
|
||||
|
||||
const cssPageContainer = styled('div', `
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
background-color: ${theme.mainPanelBg};
|
||||
`);
|
||||
|
||||
const cssOnboardingPage = styled('div', `
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
`);
|
||||
|
||||
const cssSidebar = styled('div', `
|
||||
width: 460px;
|
||||
background-color: ${colors.lightGreen};
|
||||
color: ${colors.light};
|
||||
background-image:
|
||||
linear-gradient(to bottom, rgb(41, 185, 131) 32px, transparent 32px),
|
||||
linear-gradient(to right, rgb(41, 185, 131) 32px, transparent 32px);
|
||||
background-size: 240px 120px;
|
||||
background-position: 0 0, 40%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media ${mediaMedium} {
|
||||
& {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssGetStarted = styled('div', `
|
||||
width: 500px;
|
||||
height: 350px;
|
||||
margin: auto -77px 0 37px;
|
||||
overflow: hidden;
|
||||
`);
|
||||
|
||||
const cssGetStartedImg = styled('img', `
|
||||
display: block;
|
||||
width: 500px;
|
||||
height: auto;
|
||||
`);
|
||||
|
||||
const cssSidebarContent = styled('div', `
|
||||
line-height: 32px;
|
||||
margin: 112px 16px 64px 16px;
|
||||
font-size: 24px;
|
||||
line-height: 48px;
|
||||
font-weight: 500;
|
||||
`);
|
||||
|
||||
const cssSidebarHeading1 = styled('div', `
|
||||
font-size: 32px;
|
||||
text-align: center;
|
||||
`);
|
||||
|
||||
const cssSidebarHeading2 = styled('div', `
|
||||
font-size: 28px;
|
||||
text-align: center;
|
||||
`);
|
||||
|
||||
const cssMainPanel = styled('div', `
|
||||
margin: 56px auto;
|
||||
padding: 0px 96px;
|
||||
text-align: center;
|
||||
|
||||
@media ${mediaMedium} {
|
||||
& {
|
||||
padding: 0px 32px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHeading = styled('div', `
|
||||
color: ${theme.text};
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin: 32px 0px;
|
||||
`);
|
||||
|
||||
const cssSubHeading = styled(cssHeading, `
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
margin-top: 16px;
|
||||
`);
|
||||
|
||||
const cssStep = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: "";
|
||||
width: 50px;
|
||||
height: 2px;
|
||||
background-color: var(--grist-color-light-green);
|
||||
}
|
||||
`);
|
||||
|
||||
const cssStepCircle = styled('div', `
|
||||
--icon-color: ${theme.controlPrimaryFg};
|
||||
--step-color: ${theme.controlPrimaryBg};
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 30px;
|
||||
border: 1px solid var(--step-color);
|
||||
color: var(--step-color);
|
||||
margin: 4px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
--step-color: ${theme.controlPrimaryHoverBg};
|
||||
}
|
||||
&-current {
|
||||
background-color: var(--step-color);
|
||||
color: ${theme.controlPrimaryFg};
|
||||
outline: 3px solid ${theme.cursorInactive};
|
||||
}
|
||||
&-done {
|
||||
background-color: var(--step-color);
|
||||
}
|
||||
`);
|
||||
|
||||
const cssQuestions = styled('div', `
|
||||
max-width: 500px;
|
||||
`);
|
||||
|
||||
const cssQuestion = styled('div', `
|
||||
margin: 16px 0 8px 0;
|
||||
text-align: left;
|
||||
`);
|
||||
|
||||
const cssFieldHeading = styled('div', `
|
||||
color: ${theme.text};
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
`);
|
||||
|
||||
const cssContinue = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 40px;
|
||||
gap: 16px;
|
||||
`);
|
||||
|
||||
const cssUseCases = styled('div', `
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin: -8px -4px;
|
||||
`);
|
||||
|
||||
const cssUseCase = styled('div', `
|
||||
flex: 1 0 40%;
|
||||
min-width: 200px;
|
||||
margin: 8px 4px 0 4px;
|
||||
height: 40px;
|
||||
border: 1px solid ${theme.inputBorder};
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: ${theme.text};
|
||||
--icon-color: ${theme.accentIcon};
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
&-selected {
|
||||
border: 2px solid ${theme.controlFg};
|
||||
}
|
||||
&-selected:hover {
|
||||
border: 2px solid ${theme.controlHoverFg};
|
||||
}
|
||||
&-selected:focus-within {
|
||||
box-shadow: 0 0 2px 0px ${theme.controlFg};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssUseCaseIcon = styled('div', `
|
||||
margin: 0 16px;
|
||||
--icon-color: ${theme.accentIcon};
|
||||
`);
|
||||
|
||||
const cssOtherLabel = styled('div', `
|
||||
display: block;
|
||||
|
||||
.${cssUseCase.className}-selected & {
|
||||
display: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssInput = styled(textInput, `
|
||||
height: 40px;
|
||||
`);
|
||||
|
||||
const cssOtherInput = styled(input, `
|
||||
color: ${theme.inputFg};
|
||||
display: none;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
padding: 0px;
|
||||
|
||||
&::placeholder {
|
||||
color: ${theme.inputPlaceholderFg};
|
||||
}
|
||||
.${cssUseCase.className}-selected & {
|
||||
display: block;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssTutorial = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`);
|
||||
|
||||
const cssScreenshot = styled('div', `
|
||||
max-width: 720px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
border-radius: 3px;
|
||||
border: 3px solid ${colors.lightGreen};
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
`);
|
||||
|
||||
const cssActionOverlay = styled('div', `
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.20);
|
||||
`);
|
||||
|
||||
const cssTutorialOverlay = styled(cssActionOverlay, `
|
||||
background-color: transparent;
|
||||
`);
|
||||
|
||||
const cssAction = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
`);
|
||||
|
||||
const cssVideoPlayIcon = styled(icon, `
|
||||
--icon-color: ${colors.light};
|
||||
width: 38px;
|
||||
height: 33.25px;
|
||||
`);
|
||||
|
||||
const cssCloseIcon = styled(icon, `
|
||||
--icon-color: ${colors.light};
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
`);
|
||||
|
||||
const cssYouTubePlayer = styled('iframe', `
|
||||
border-radius: 4px;
|
||||
`);
|
||||
|
||||
const cssModalHeader = styled('div', `
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
`);
|
||||
|
||||
const cssModalBody = styled('div', `
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`);
|
||||
|
||||
const cssBackButton = styled(bigBasicButton, `
|
||||
border: none;
|
||||
`);
|
||||
|
||||
const cssModalButtons = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
`);
|
||||
|
||||
const cssVideoPlayer = styled('div', `
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
height: 100%;
|
||||
max-height: 720px;
|
||||
|
||||
@media ${mediaXSmall} {
|
||||
& {
|
||||
max-height: 240px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssVideoPlayerModal = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
`);
|
||||
|
||||
const cssModalCloseButton = styled('div', `
|
||||
margin-bottom: 8px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssScreenshotImg = styled('img', `
|
||||
transform: scale(1.2);
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
const cssTutorialScreenshotImg = styled('img', `
|
||||
width: 100%;
|
||||
opacity: 0.4;
|
||||
`);
|
||||
|
||||
const cssRoundButton = styled('div', `
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 100px;
|
||||
background: ${colors.lightGreen};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
--icon-color: var(--light, #FFF);
|
||||
|
||||
.${cssScreenshot.className}:hover & {
|
||||
background: ${colors.darkGreen};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssStepper = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
text-transform: uppercase;
|
||||
`);
|
||||
|
||||
const cssTutorialButton = styled(bigPrimaryButtonLink, `
|
||||
.${cssScreenshot.className}:hover & {
|
||||
background-color: ${theme.controlPrimaryHoverBg};
|
||||
border-color: ${theme.controlPrimaryHoverBg};
|
||||
}
|
||||
`);
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user