(core) Replace questionnaire for new users with a popup asking for just their primary use-case.

Summary:
- WelcomeQuestions implements the new popup.
- Popup shows up on any doc-list page, the first time the user visits one after
  signing up and setting their name.
- Submits responses to the same "New User Questions" doc, which has been
  changed to accept two new columns (ChoiceList of use_cases, and Text for
  use_other).
- Improve modals on mobile along the way.

Test Plan: Added browser tests and tested manually

Reviewers: alexmojaki

Reviewed By: alexmojaki

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D3213
This commit is contained in:
Dmitry S
2022-01-13 21:55:55 -05:00
parent ba6ecc5e9e
commit 215bb90e68
19 changed files with 335 additions and 47 deletions

View File

@@ -7,7 +7,9 @@ import {Features} from 'app/common/Features';
import {GristLoadConfig} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI';
import {LocalPlugin} from 'app/common/plugin';
import {UserPrefs} from 'app/common/Prefs';
import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {getUserPrefsObs} from 'app/client/models/UserPrefs';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
export {reportError} from 'app/client/models/errors';
@@ -58,6 +60,7 @@ export interface AppModel {
orgError?: OrgError; // If currentOrg is null, the error that caused it.
currentFeatures: Features; // features of the current org's product.
userPrefsObs: Observable<UserPrefs>;
pageType: Observable<PageType>;
@@ -177,6 +180,8 @@ export class AppModelImpl extends Disposable implements AppModel {
public readonly currentFeatures = (this.currentOrg && this.currentOrg.billingAccount) ?
this.currentOrg.billingAccount.product.features : {};
public readonly userPrefsObs = getUserPrefsObs(this);
// Get the current PageType from the URL.
public readonly pageType: Observable<PageType> = Computed.create(this, urlState().state,
(use, state) => (state.doc ? "doc" : (state.billing ? "billing" : (state.welcome ? "welcome" : "home"))));

View File

@@ -1,40 +1,60 @@
import {localStorageObs} from 'app/client/lib/localStorageObs';
import {AppModel} from 'app/client/models/AppModel';
import {UserOrgPrefs} from 'app/common/Prefs';
import {UserOrgPrefs, UserPrefs} from 'app/common/Prefs';
import {Computed, Observable} from 'grainjs';
/**
* Creates an observable that returns UserOrgPrefs, and which stores them when set.
*
* For anon user, the prefs live in localStorage. Note that the observable isn't actually watching
* for changes on the server, it will only change when set.
*/
export function getUserOrgPrefsObs(appModel: AppModel): Observable<UserOrgPrefs> {
const savedPrefs = appModel.currentValidUser ? appModel.currentOrg?.userOrgPrefs : undefined;
if (savedPrefs) {
const prefsObs = Observable.create<UserOrgPrefs>(null, savedPrefs);
return Computed.create(null, (use) => use(prefsObs))
.onWrite(userOrgPrefs => {
prefsObs.set(userOrgPrefs);
return appModel.api.updateOrg('current', {userOrgPrefs});
});
} else {
const userId = appModel.currentUser?.id || 0;
const jsonPrefsObs = localStorageObs(`userOrgPrefs:u=${userId}`);
return Computed.create(null, jsonPrefsObs, (use, p) => (p && JSON.parse(p) || {}) as UserOrgPrefs)
.onWrite(userOrgPrefs => {
jsonPrefsObs.set(JSON.stringify(userOrgPrefs));
});
}
interface PrefsTypes {
userOrgPrefs: UserOrgPrefs;
userPrefs: UserPrefs;
}
/**
* Creates an observable that returns a particular preference value from `prefsObs`, and which
* stores it when set.
*/
export function getUserOrgPrefObs<Name extends keyof UserOrgPrefs>(
prefsObs: Observable<UserOrgPrefs>, prefName: Name
): Observable<UserOrgPrefs[Name]> {
return Computed.create(null, (use) => use(prefsObs)[prefName])
.onWrite(value => prefsObs.set({...prefsObs.get(), [prefName]: value}));
function makePrefFunctions<P extends keyof PrefsTypes>(prefsTypeName: P) {
type PrefsType = PrefsTypes[P];
/**
* Creates an observable that returns UserOrgPrefs, and which stores them when set.
*
* For anon user, the prefs live in localStorage. Note that the observable isn't actually watching
* for changes on the server, it will only change when set.
*/
function getPrefsObs(appModel: AppModel): Observable<PrefsType> {
const savedPrefs = appModel.currentValidUser ? appModel.currentOrg?.[prefsTypeName] : undefined;
if (savedPrefs) {
const prefsObs = Observable.create<PrefsType>(null, savedPrefs!);
return Computed.create(null, (use) => use(prefsObs))
.onWrite(prefs => {
prefsObs.set(prefs);
return appModel.api.updateOrg('current', {[prefsTypeName]: prefs});
});
} else {
const userId = appModel.currentUser?.id || 0;
const jsonPrefsObs = localStorageObs(`${prefsTypeName}:u=${userId}`);
return Computed.create(null, jsonPrefsObs, (use, p) => (p && JSON.parse(p) || {}) as PrefsType)
.onWrite(prefs => {
jsonPrefsObs.set(JSON.stringify(prefs));
});
}
}
/**
* Creates an observable that returns a particular preference value from `prefsObs`, and which
* stores it when set.
*/
function getPrefObs<Name extends keyof PrefsType>(
prefsObs: Observable<PrefsType>, prefName: Name
): Observable<PrefsType[Name]> {
return Computed.create(null, (use) => use(prefsObs)[prefName])
.onWrite(value => prefsObs.set({...prefsObs.get(), [prefName]: value}));
}
return {getPrefsObs, getPrefObs};
}
// Functions actually exported are:
// - getUserOrgPrefsObs(appModel): Observsble<UserOrgPrefs>
// - getUserOrgPrefObs(userOrgPrefsObs, prefName): Observsble<PrefType[prefName]>
// - getUserPrefsObs(appModel): Observsble<UserPrefs>
// - getUserPrefObs(userPrefsObs, prefName): Observsble<PrefType[prefName]>
export const {getPrefsObs: getUserOrgPrefsObs, getPrefObs: getUserOrgPrefObs} = makePrefFunctions('userOrgPrefs');
export const {getPrefsObs: getUserPrefsObs, getPrefObs: getUserPrefObs} = makePrefFunctions('userPrefs');

View File

@@ -13,6 +13,7 @@ import {buildHomeIntro} from 'app/client/ui/HomeIntro';
import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {transition} from 'app/client/ui/transitions';
import {showWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
import {colors} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
@@ -48,6 +49,7 @@ export function createDocMenu(home: HomeModel) {
function createLoadedDocMenu(home: HomeModel) {
const flashDocId = observable<string|null>(null);
return css.docList(
showWelcomeQuestions(home.app.userPrefsObs),
dom.maybe(!home.app.currentFeatures.workspaces, () => [
css.docListHeader('This service is not available right now'),
dom('span', '(The organization needs a paid plan)')

View File

@@ -0,0 +1,161 @@
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 {dom, input, Observable, styled, subscribeElem} from 'grainjs';
export function showWelcomeQuestions(userPrefsObs: Observable<UserPrefs>) {
if (!userPrefsObs.get()?.showNewUserQuestions) {
return null;
}
return 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 selected = choices.filter((c, i) => selection[i].get()).map(c => c.text);
const use_cases = ['L', ...selected]; // Format to populate a ChoiceList column
const use_other = selected.includes('Other') ? otherText.get() : '';
const submitUrl = new URL(window.location.href);
submitUrl.pathname = '/welcome/info';
return BaseAPI.requestJson(submitUrl.href,
{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));
return {
title: [cssLogo(), dom('div', '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, text: string}> = [
{icon: 'UseProduct', color: `${colors.lightGreen}`, text: 'Product Development' },
{icon: 'UseFinance', color: '#0075A2', text: 'Finance & Accounting'},
{icon: 'UseMedia', color: '#F7B32B', text: 'Media Production' },
{icon: 'UseMonitor', color: '#F2545B', text: 'IT & Technology' },
{icon: 'UseChart', color: '#7141F9', text: 'Marketing' },
{icon: 'UseScience', color: '#231942', text: 'Research' },
{icon: 'UseSales', color: '#885A5A', text: 'Sales' },
{icon: 'UseEducate', color: '#4A5899', text: 'Education' },
{icon: 'UseHr', color: '#688047', text: 'HR & Management' },
{icon: 'UseOther', color: '#929299', text: 'Other' },
];
function buildInfoForm(selection: Observable<boolean>[], otherText: Observable<string>) {
return [
dom('span', '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' ?
item.text :
[
cssOtherLabel(item.text),
cssOtherInput(otherText, {}, {type: 'text', placeholder: 'Type here'},
// The following subscribes to changes to selection observable, and focuses the input when
// this item is selected.
(elem) => subscribeElem(elem, selection[i], val => val && setTimeout(() => elem.focus(), 0)),
// It's annoying if clicking into the input toggles selection; better to turn that
// off (user can click icon to deselect).
dom.on('click', ev => ev.stopPropagation()),
// Similarly, ignore Enter/Escape in "Other" textbox, so that they don't submit/close the form.
dom.onKeyDown({
Enter: (ev, elem) => elem.blur(),
Escape: (ev, elem) => elem.blur(),
}),
)
]
)
)),
testId('welcome-questions'),
),
];
}
const cssModalCentered = styled('div', `
text-align: center;
`);
const cssLogo = styled('div', `
display: inline-block;
height: 48px;
width: 48px;
background-image: var(--icon-GristLogo);
background-size: 32px 32px;
background-repeat: no-repeat;
background-position: center;
`);
const cssChoices = styled('div', `
display: flex;
flex-wrap: wrap;
align-items: center;
margin-top: 24px;
`);
const cssChoice = styled('div', `
flex: 1 0 40%;
min-width: 0px;
margin: 8px 4px 0 4px;
height: 40px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
display: flex;
align-items: center;
text-align: left;
cursor: pointer;
&:hover {
border-color: ${colors.lightGreen};
}
&-selected {
background-color: ${colors.mediumGrey};
}
&-selected:hover {
border-color: ${colors.darkGreen};
}
&-selected:focus-within {
box-shadow: 0 0 2px 0px var(--grist-color-cursor);
border-color: ${colors.lightGreen};
}
`);
const cssIcon = styled('div', `
margin: 0 16px;
`);
const cssOtherLabel = styled('div', `
display: block;
.${cssChoice.className}-selected & {
display: none;
}
`);
const cssOtherInput = styled(input, `
display: none;
border: none;
background: none;
outline: none;
padding: 0px;
.${cssChoice.className}-selected & {
display: block;
}
`);

View File

@@ -97,7 +97,17 @@ export type IconName = "ChartArea" |
"Warning" |
"Widget" |
"Wrap" |
"Zoom";
"Zoom" |
"UseChart" |
"UseEducate" |
"UseFinance" |
"UseHr" |
"UseMedia" |
"UseMonitor" |
"UseOther" |
"UseProduct" |
"UseSales" |
"UseScience";
export const IconList: IconName[] = ["ChartArea",
"ChartBar",
@@ -198,4 +208,14 @@ export const IconList: IconName[] = ["ChartArea",
"Warning",
"Widget",
"Wrap",
"Zoom"];
"Zoom",
"UseChart",
"UseEducate",
"UseFinance",
"UseHr",
"UseMedia",
"UseMonitor",
"UseOther",
"UseProduct",
"UseSales",
"UseScience"];

View File

@@ -2,7 +2,7 @@ import {FocusLayer} from 'app/client/lib/FocusLayer';
import * as Mousetrap from 'app/client/lib/Mousetrap';
import {reportError} from 'app/client/models/errors';
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {colors, mediaSmall, testId, vars} from 'app/client/ui2018/cssVars';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {waitGrainObs} from 'app/common/gutil';
import {Computed, Disposable, dom, DomContents, DomElementArg, MultiHolder, Observable, styled} from 'grainjs';
@@ -347,6 +347,13 @@ const cssModalDialog = styled('div', `
&-fixed-wide {
width: 600px;
}
@media ${mediaSmall} {
& {
width: unset;
min-width: unset;
padding: 24px 16px;
}
}
`);
export const cssModalTitle = styled('div', `
@@ -381,6 +388,7 @@ const cssModalBacker = styled('div', `
height: 100%;
top: 0;
left: 0;
padding: 16px;
z-index: 999;
background-color: ${colors.backdrop};
overflow-y: auto;

View File

@@ -6,14 +6,18 @@ export type SortPref = typeof SortPref.type;
export const ViewPref = StringUnion("list", "icons");
export type ViewPref = typeof ViewPref.type;
// A collection of preferences related to a user or org (or combination).
export interface Prefs {
// TODO replace this with real preferences.
// A dummy field used only in tests.
placeholder?: string;
}
export type UserPrefs = Prefs;
// A collection of preferences related to a user or org (or combination).
export interface UserPrefs extends Prefs {
// Whether to ask the user to fill out a form about their use-case, on opening the DocMenu page.
// Set to true on first login, then reset when the form is closed, so that it only shows once.
showNewUserQuestions?: boolean;
}
export interface UserOrgPrefs extends Prefs {
docMenuSort?: SortPref;

View File

@@ -1095,10 +1095,20 @@ export class FlexServer implements GristServer {
if (req.params.page === 'user') {
const name: string|undefined = req.body && req.body.username || undefined;
// Reset isFirstTimeUser flag, used to redirect a new user to the /welcome/user page.
await this._dbManager.updateUser(userId, {name, isFirstTimeUser: false});
redirectPath = '/welcome/info';
// This is a good time to set another flag (showNewUserQuestions), to show a popup with
// welcome question(s) to this new user. Both flags are 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).
await this._dbManager.updateOrg(getScope(req), 0, {userPrefs: {showNewUserQuestions: true}});
} else if (req.params.page === 'info') {
// The /welcome/info page is no longer part of any flow, but if visited, will still submit
// here and redirect. The new form with new-user questions appears in a modal popup. It
// also posts here to save answers, but ignores the response.
const user = getUser(req);
const row = {...req.body, UserID: userId, Name: user.name, Email: user.loginEmail};
this._recordNewUserInfo(row)
@@ -1106,14 +1116,14 @@ export class FlexServer implements GristServer {
// 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});
});
}
// redirect to teams page if users has access to more than one org. Otherwise redirect to
// personal org.
const result = await this._dbManager.getMergedOrgs(userId, userId, domain || null);
const orgs = (result.status === 200) ? result.data : null;
if (orgs && orgs.length > 1) {
redirectPath = '/welcome/teams';
}
// redirect to teams page if users has access to more than one org. Otherwise redirect to
// personal org.
const result = await this._dbManager.getMergedOrgs(userId, userId, domain || null);
const orgs = (result.status === 200) ? result.data : null;
if (orgs && orgs.length > 1) {
redirectPath = '/welcome/teams';
}
const mergedOrgDomain = this._dbManager.mergedOrgDomain();