(core) Show Grist video tour after welcome questions

Summary:
After the welcome questions are dismissed, a video tour modal will
now be displayed. The video tour is also accessible via a tool button
in the left panel of the home page, as well as a text button next to
the Examples & Templates header.

Test Plan: Browser tests.

Reviewers: dsagal

Reviewed By: dsagal

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D3477
pull/214/head
George Gevoian 2 years ago
parent a91d493ffc
commit abebe812db

@ -102,7 +102,17 @@ exports.groups = [{
name: 'openWidgetConfiguration',
keys: [],
desc: 'Open Custom widget configuration screen',
}
},
{
name: 'leftPanelOpen',
keys: [],
desc: 'Shortcut to open the left panel',
},
{
name: 'videoTourToolsOpen',
keys: [],
desc: 'Shortcut to open video tour from home left panel',
},
]
}, {
group: 'Navigation',

@ -10,6 +10,7 @@ import {getTimeFromNow, HomeModel, makeLocalViewSettings, ViewSettings} from 'ap
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
import * as css from 'app/client/ui/DocMenuCss';
import {buildHomeIntro} from 'app/client/ui/HomeIntro';
import {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour';
import {buildUpgradeNudge} from 'app/client/ui/ProductUpgrades';
import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
import {shadowScroll} from 'app/client/ui/shadowScroll';
@ -25,7 +26,7 @@ import {IHomePage} from 'app/common/gristUrls';
import {SortPref, ViewPref} from 'app/common/Prefs';
import * as roles from 'app/common/roles';
import {Document, Workspace} from 'app/common/UserAPI';
import {Computed, computed, dom, DomContents, makeTestId, Observable, observable, styled} from 'grainjs';
import {Computed, computed, dom, DomContents, makeTestId, Observable, observable} from 'grainjs';
import sortBy = require('lodash/sortBy');
import {buildTemplateDocs} from 'app/client/ui/TemplateDocs';
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
@ -67,7 +68,7 @@ function createLoadedDocMenu(home: HomeModel) {
const flashDocId = observable<string|null>(null);
return css.docList(
showWelcomeQuestions(home.app.userPrefsObs),
cssDocMenu(
css.docMenu(
dom.maybe(!home.app.currentFeatures.workspaces, () => [
css.docListHeader('This service is not available right now'),
dom('span', '(The organization needs a paid plan)')
@ -208,15 +209,18 @@ function buildAllDocsTemplates(home: HomeModel, viewSettings: ViewSettings) {
if (templates.length === 0) { return null; }
const hideTemplatesObs = localStorageBoolObs('hide-examples');
return css.templatesDocBlock(
return css.allDocsTemplates(css.templatesDocBlock(
dom.autoDispose(hideTemplatesObs),
css.templatesHeader(
'Examples & Templates',
dom.domComputed(hideTemplatesObs, (collapsed) =>
collapsed ? css.templatesHeaderIcon('Expand') : css.templatesHeaderIcon('Collapse')
css.templatesHeaderWrap(
css.templatesHeader(
'Examples & Templates',
dom.domComputed(hideTemplatesObs, (collapsed) =>
collapsed ? css.templatesHeaderIcon('Expand') : css.templatesHeaderIcon('Collapse')
),
dom.on('click', () => hideTemplatesObs.set(!hideTemplatesObs.get())),
testId('all-docs-templates-header'),
),
dom.on('click', () => hideTemplatesObs.set(!hideTemplatesObs.get())),
testId('all-docs-templates-header'),
createVideoTourTextButton(),
),
dom.maybe((use) => !use(hideTemplatesObs), () => [
buildTemplateDocs(home, templates, viewSettings),
@ -228,7 +232,7 @@ function buildAllDocsTemplates(home: HomeModel, viewSettings: ViewSettings) {
]),
css.docBlock.cls((use) => '-' + use(home.currentView)),
testId('all-docs-templates'),
);
));
});
}
@ -586,7 +590,3 @@ function shouldShowTemplates(home: HomeModel, showIntro: boolean): boolean {
// Show templates for all personal orgs, and for non-personal orgs when showing intro.
return isPersonalOrg || showIntro;
}
const cssDocMenu = styled('div', `
flex-grow: 1;
`);

@ -8,6 +8,11 @@ import {bigBasicButton} from 'app/client/ui2018/buttons';
// styles, which gives it priority.
import 'popweasel';
export const docMenu = styled('div', `
flex-grow: 1;
max-width: 100%;
`);
// The "&:after" clause forces some padding below all docs.
export const docList = styled('div', `
height: 100%;
@ -33,16 +38,34 @@ export const docList = styled('div', `
}
`);
export const docListHeader = styled('div', `
const listHeader = styled('div', `
min-height: 32px;
line-height: 32px;
margin-bottom: 24px;
color: ${colors.dark};
font-size: ${vars.xxxlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
`);
export const templatesHeader = styled(docListHeader, `
export const docListHeader = styled(listHeader, `
margin-bottom: 24px;
`);
export const templatesHeaderWrap = styled('div', `
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
@media ${mediaSmall} {
& {
flex-direction: column;
align-items: flex-start;
}
}
`);
export const templatesHeader = styled(listHeader, `
cursor: pointer;
`);
@ -53,13 +76,18 @@ export const featuredTemplatesHeader = styled(docListHeader, `
export const otherSitesHeader = templatesHeader;
export const allDocsTemplates = styled('div', `
display: flex;
`);
export const docBlock = styled('div', `
max-width: 550px;
min-width: 300px;
margin-bottom: 28px;
&-icons {
max-width: unset;
max-width: max-content;
min-width: calc(min(550px, 100%));
}
`);

@ -6,7 +6,8 @@ import {HomeModel} from 'app/client/models/HomeModel';
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
import {docImport, importFromPlugin} from 'app/client/ui/HomeImports';
import {cssLinkText, cssPageEntry, cssPageIcon, cssPageLink} from 'app/client/ui/LeftPanelCommon';
import {cssLinkText, cssPageEntry, cssPageIcon, cssPageLink, cssSpacer} from 'app/client/ui/LeftPanelCommon';
import {createVideoTourToolsButton} from 'app/client/ui/OpenVideoTour';
import {transientInput} from 'app/client/ui/transientInput';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
@ -111,6 +112,8 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
testId('dm-trash'),
),
),
cssSpacer(),
createVideoTourToolsButton(),
createHelpTools(home.app),
)
)

@ -24,28 +24,25 @@ import {dom, DomContents, Observable, styled} from 'grainjs';
* Creates the "help tools", a button/link to open HelpScout beacon, and one to open the
* HelpCenter in a new tab.
*/
export function createHelpTools(appModel: AppModel, spacer = true): DomContents {
export function createHelpTools(appModel: AppModel): DomContents {
if (shouldHideUiElement("helpCenter")) {
return [];
}
return [
spacer ? cssSpacer() : null,
cssSplitPageEntry(
cssPageEntryMain(
cssPageLink(cssPageIcon('Help'),
cssLinkText('Help Center'),
dom.cls('tour-help-center'),
dom.on('click', (ev) => beaconOpenMessage({appModel})),
testId('left-feedback'),
),
return cssSplitPageEntry(
cssPageEntryMain(
cssPageLink(cssPageIcon('Help'),
cssLinkText('Help Center'),
dom.cls('tour-help-center'),
dom.on('click', (ev) => beaconOpenMessage({appModel})),
testId('left-feedback'),
),
),
cssPageEntrySmall(
cssPageLink(cssPageIcon('FieldLink'),
{href: commonUrls.help, target: '_blank'},
),
cssPageEntrySmall(
cssPageLink(cssPageIcon('FieldLink'),
{href: commonUrls.help, target: '_blank'},
),
)
),
];
);
}
/**
@ -56,6 +53,7 @@ export function leftPanelBasic(appModel: AppModel, panelOpen: Observable<boolean
cssScrollPane(
cssTools(
cssTools.cls('-collapsed', (use) => !use(panelOpen)),
cssSpacer(),
createHelpTools(appModel),
)
)

@ -0,0 +1,142 @@
import * as commands from 'app/client/components/commands';
import {cssLinkText, cssPageEntryMain, cssPageIcon, cssPageLink} from 'app/client/ui/LeftPanelCommon';
import {colors} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {modal} from 'app/client/ui2018/modals';
import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls';
import {dom, makeTestId, styled} from 'grainjs';
const testId = makeTestId('test-video-tour-');
/**
* Opens a modal containing a video tour of Grist.
*/
export function openVideoTour(refElement: HTMLElement) {
return modal(
(ctl) => {
return [
cssModal.cls(''),
cssCloseButton(
cssCloseIcon('CrossBig'),
dom.on('click', () => ctl.close()),
testId('close'),
),
cssVideoWrap(
cssVideo(
{
src: commonUrls.videoTour,
title: 'YouTube video player',
frameborder: '0',
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
allowfullscreen: '',
},
),
),
testId('modal'),
];
},
{
refElement,
variant: 'collapsing',
}
);
}
/**
* Creates a text button that shows the video tour on click.
*/
export function createVideoTourTextButton(): HTMLDivElement {
const elem: HTMLDivElement = cssVideoTourTextButton(
cssVideoIcon('Video'),
'Grist Video Tour',
dom.on('click', () => openVideoTour(elem)),
testId('text-button'),
);
return elem;
}
/**
* Creates the "Video Tour" button for the "Tools" section of the left panel.
*
* Shows the video tour on click.
*/
export function createVideoTourToolsButton(): HTMLDivElement | null {
if (shouldHideUiElement('helpCenter')) { return null; }
let iconElement: HTMLElement;
const commandsGroup = commands.createGroup({
videoTourToolsOpen: () => openVideoTour(iconElement),
}, null, true);
return cssPageEntryMain(
dom.autoDispose(commandsGroup),
cssPageLink(
iconElement = cssPageIcon('Video'),
cssLinkText('Video Tour'),
dom.cls('tour-help-center'),
dom.on('click', () => openVideoTour(iconElement)),
testId('tools-button'),
),
);
}
const cssModal = styled('div', `
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
width: 100%;
max-width: 864px;
`);
const cssVideoWrap = styled('div', `
position: relative;
padding-bottom: 56.25%;
height: 0;
`);
const cssVideo = styled('iframe', `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
`);
const cssVideoTourTextButton = styled('div', `
color: ${colors.lightGreen};
cursor: pointer;
&:hover {
color: ${colors.darkGreen};
}
`);
const cssVideoIcon = styled(icon, `
background-color: ${colors.lightGreen};
cursor: pointer;
margin: 0px 4px 3px 0;
.${cssVideoTourTextButton.className}:hover > & {
background-color: ${colors.darkGreen};
}
`);
const cssCloseButton = styled('div', `
align-self: flex-end;
margin: -8px;
padding: 4px;
border-radius: 4px;
cursor: pointer;
--icon-color: ${colors.slate};
&:hover {
background-color: ${colors.mediumGreyOpaque};
}
`);
const cssCloseIcon = styled(icon, `
padding: 12px;
`);

@ -1,9 +1,10 @@
/**
* Note that it assumes the presence of cssVars.cssRootVars on <body>.
*/
import * as commands from 'app/client/components/commands';
import {urlState} from "app/client/models/gristUrlState";
import {resizeFlexVHandle} from 'app/client/ui/resizeHandle';
import {transition} from 'app/client/ui/transitions';
import {transition, TransitionWatcher} from 'app/client/ui/transitions';
import {colors, cssHideForNarrowScreen, mediaNotSmall, mediaSmall} from 'app/client/ui2018/cssVars';
import {isNarrowScreenObs} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
@ -40,6 +41,7 @@ export function pagePanels(page: PageContents) {
let lastLeftOpen = left.panelOpen.get();
let lastRightOpen = right?.panelOpen.get() || false;
let leftPaneDom: HTMLElement;
// When switching to mobile mode, close panels; when switching to desktop, restore the
// last desktop state.
@ -60,12 +62,21 @@ export function pagePanels(page: PageContents) {
}
});
const commandsGroup = commands.createGroup({
leftPanelOpen: () => new Promise((resolve) => {
const watcher = new TransitionWatcher(leftPaneDom);
watcher.onDispose(() => resolve(undefined));
left.panelOpen.set(true);
}),
}, null, true);
return cssPageContainer(
dom.autoDispose(sub1),
dom.autoDispose(sub2),
dom.autoDispose(commandsGroup),
page.contentTop,
cssContentMain(
cssLeftPane(
leftPaneDom = cssLeftPane(
testId('left-panel'),
cssTopHeader(left.header),
left.content,
@ -187,6 +198,7 @@ function toggleObs(boolObs: Observable<boolean>) {
boolObs.set(!boolObs.get());
}
const bottomFooterHeightPx = 48;
const cssVBox = styled('div', `
display: flex;
flex-direction: column;
@ -207,7 +219,7 @@ const cssPageContainer = styled(cssVBox, `
@media ${mediaSmall} {
& {
padding-bottom: 48px;
padding-bottom: ${bottomFooterHeightPx}px;
min-width: 240px;
}
.interface-light & {
@ -232,7 +244,7 @@ export const cssLeftPane = styled(cssVBox, `
position: fixed;
z-index: 10;
top: 0;
bottom: 0;
bottom: ${bottomFooterHeightPx}px;
left: -${240 + 15}px; /* adds an extra 15 pixels to also hide the box shadow */
visibility: hidden;
box-shadow: 10px 0 5px rgba(0, 0, 0, 0.2);
@ -279,7 +291,7 @@ const cssRightPane = styled(cssVBox, `
position: fixed;
z-index: 10;
top: 0;
bottom: 0;
bottom: ${bottomFooterHeightPx}px;
right: -${240 + 15}px; /* adds an extra 15 pixels to also hide the box shadow */
box-shadow: -10px 0 5px rgba(0, 0, 0, 0.2);
visibility: hidden;
@ -324,7 +336,7 @@ const cssTopHeader = styled('div', `
}
`);
const cssBottomFooter = styled ('div', `
height: 48px;
height: ${bottomFooterHeightPx}px;
background-color: white;
z-index: 20;
display: flex;

@ -121,6 +121,11 @@ const pinnedDocWrapper = styled('div', `
&:hover {
border: 1px solid ${colors.slate};
}
/* TODO: Specify a gap on flexbox parents of pinnedDocWrapper instead. */
&:last-child {
margin-right: 0px;
}
`);
const pinnedDoc = styled('a', `

@ -114,7 +114,7 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
)
),
),
createHelpTools(docPageModel.appModel, false)
createHelpTools(docPageModel.appModel),
);
}

@ -1,3 +1,4 @@
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';
@ -29,9 +30,16 @@ export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
{method: 'POST', body: JSON.stringify({use_cases, use_other})});
}
// 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.)
owner.onDispose(() => showQuestions.set(undefined));
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', 'Welcome to Grist!')],

@ -1,6 +1,7 @@
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {reportError} from 'app/client/models/errors';
import {cssInput} from 'app/client/ui/MakeCopyMenu';
import {prepareForTransition, TransitionWatcher} from 'app/client/ui/transitions';
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
import {colors, mediaSmall, testId, vars} from 'app/client/ui2018/cssVars';
import {loadingSpinner} from 'app/client/ui2018/loaders';
@ -112,10 +113,25 @@ export class ModalControl extends Disposable implements IModalControl {
}
}
export interface IModalOptions {
noEscapeKey?: boolean; // If set, escape key does not close the dialog
noClickAway?: boolean; // If set, clicking into background does not close dialog.
/**
* The modal variant.
*
* Fade-in modals open with a fade-in background animation, and close immediately.
*
* Collapsing modals open with a expanding animation from a referenced DOM element, and
* close with a collapsing animation into the referenced element.
*/
export type IModalVariant = 'fade-in' | 'collapsing';
export interface IModalOptions {
// The modal variant. Defaults to "fade-in".
variant?: IModalVariant;
// Required for "collapsing" variant modals. This is the anchor element for animations.
refElement?: HTMLElement;
// If set, escape key does not close the dialog.
noEscapeKey?: boolean;
// If set, clicking into background does not close dialog.
noClickAway?: boolean;
// If given, call and wait for this before closing the dialog. If it returns false, don't close.
// Error also prevents closing, and is reported as an unexpected error.
beforeClose?: () => Promise<boolean>;
@ -153,41 +169,78 @@ export function modal(
createFn: (ctl: IModalControl, owner: MultiHolder) => DomElementArg,
options: IModalOptions = {}
): void {
const {noEscapeKey, noClickAway, refElement = document.body, variant = 'fade-in'} = options;
function doClose() {
if (!modalDom.isConnected) { return; }
variant === 'collapsing' ? collapseAndCloseModal() : closeModal();
}
function closeModal() {
document.body.removeChild(modalDom);
// Ensure we run the disposers for the DOM contained in the modal.
dom.domDispose(modalDom);
}
function collapseAndCloseModal() {
const watcher = new TransitionWatcher(dialogDom);
watcher.onDispose(() => closeModal());
modalDom.classList.add(cssModalBacker.className + '-collapsing');
collapseModal();
}
function expandModal() {
prepareForTransition(dialogDom, () => collapseModal());
Object.assign(dialogDom.style, {
transform: '',
opacity: '',
visibility: 'visible',
});
}
function collapseModal() {
const rect = dialogDom.getBoundingClientRect();
const collapsedRect = refElement.getBoundingClientRect();
const originX = (collapsedRect.left + collapsedRect.width / 2) - rect.left;
const originY = (collapsedRect.top + collapsedRect.height / 2) - rect.top;
Object.assign(dialogDom.style, {
transform: `scale(${collapsedRect.width / rect.width}, ${collapsedRect.height / rect.height})`,
transformOrigin: `${originX}px ${originY}px`,
opacity: '0',
});
}
let close = doClose;
let dialogDom: HTMLElement;
const modalDom = cssModalBacker(
dom.create((owner) => {
const focus = () => dialog.focus();
const focus = () => dialogDom.focus();
const ctl = ModalControl.create(owner, doClose, focus);
close = () => ctl.close();
const dialog = cssModalDialog(
dialogDom = cssModalDialog(
createFn(ctl, owner),
cssModalDialog.cls('-collapsing', variant === 'collapsing'),
dom.on('click', (ev) => ev.stopPropagation()),
options.noEscapeKey ? null : dom.onKeyDown({ Escape: close }),
testId('modal-dialog')
noEscapeKey ? null : dom.onKeyDown({ Escape: close }),
testId('modal-dialog'),
);
FocusLayer.create(owner, {
defaultFocusElem: dialog,
defaultFocusElem: dialogDom,
allowFocus: (elem) => (elem !== document.body),
// Pause mousetrap keyboard shortcuts while the modal is shown. Without this, arrow keys
// will navigate in a grid underneath the modal, and Enter may open a cell there.
pauseMousetrap: true
});
return dialog;
return dialogDom;
}),
options.noClickAway ? null : dom.on('click', () => close()),
noClickAway ? null : dom.on('click', () => close()),
);
document.body.appendChild(modalDom);
if (variant === 'collapsing') { expandModal(); }
}
export interface ISaveModalOptions {
@ -436,6 +489,11 @@ const cssModalDialog = styled('div', `
&-fixed-wide {
width: 600px;
}
&-collapsing {
transition-property: opacity, transform;
transition-duration: 0.4s;
transition-timing-function: ease-in-out;
}
@media ${mediaSmall} {
& {
width: unset;
@ -471,6 +529,10 @@ const cssFadeIn = keyframes(`
from {background-color: transparent}
`);
const cssFadeOut = keyframes(`
from {background-color: ${colors.backdrop}}
`);
const cssModalBacker = styled('div', `
position: fixed;
display: flex;
@ -485,6 +547,11 @@ const cssModalBacker = styled('div', `
overflow-y: auto;
animation-name: ${cssFadeIn};
animation-duration: 0.4s;
&-collapsing {
animation-name: ${cssFadeOut};
background-color: transparent;
}
`);
const cssSpinner = styled('div', `

@ -64,6 +64,7 @@ export const commonUrls = {
efcrConnect: 'https://efc-r.com/connect',
efcrHelp: 'https://www.nioxus.info/eFCR-Help',
videoTour: 'https://www.youtube.com/embed/qnr2Pfnxdlc?autoplay=1',
};
/**

Loading…
Cancel
Save