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/D3213pull/85/head
@ -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');
|
||||
|
@ -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;
|
||||
}
|
||||
`);
|
After Width: | Height: | Size: 675 B |
After Width: | Height: | Size: 695 B |
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 260 B |
After Width: | Height: | Size: 497 B |
After Width: | Height: | Size: 575 B |
After Width: | Height: | Size: 2.6 KiB |
After Width: | Height: | Size: 1.4 KiB |