(core) Polishing upgrade plan UI

Summary:
- Update nudge boxes content and collapsing on personal and free team site
- New confirmation after upgrading from a free team site
- Refactoring ProductUpgrade code, splitting plans / modals and nudges

Test Plan: Manual and updated tests

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3481
This commit is contained in:
Jarosław Sadziński
2022-06-29 12:19:20 +02:00
parent dd2eadc86e
commit aefe451bab
16 changed files with 203 additions and 76 deletions

View File

@@ -1,3 +1,4 @@
import {safeJsonParse} from 'app/common/gutil';
import {Observable} from 'grainjs';
/**
@@ -96,3 +97,14 @@ export function localStorageObs(key: string, defaultValue?: string): Observable<
obs.addListener((val) => (val === null) ? store.removeItem(key) : store.setItem(key, val));
return obs;
}
/**
* Helper to create a JSON observable whose state is stored in localStorage.
*/
export function localStorageJsonObs<T>(key: string, defaultValue: T): Observable<T> {
const store = getStorage();
const currentValue = safeJsonParse(store.getItem(key) || '', defaultValue ?? null);
const obs = Observable.create<T>(null, currentValue);
obs.addListener((val) => (val === null) ? store.removeItem(key) : store.setItem(key, JSON.stringify(val ?? null)));
return obs;
}

View File

@@ -50,6 +50,10 @@ export interface TopAppModel {
* Returns the UntrustedContentOrigin use settings. Throws if not defined.
*/
getUntrustedContentOrigin(): string;
/**
* Reloads orgs and accounts for current user.
*/
fetchUsersAndOrgs(): Promise<void>;
}
// AppModel is specific to the currently loaded organization and active user. It gets rebuilt when
@@ -110,7 +114,7 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize()));
this.plugins = this._gristConfig?.plugins || [];
this._fetchUsersAndOrgs().catch(reportError);
this.fetchUsersAndOrgs().catch(reportError);
}
public initialize(): void {
@@ -143,6 +147,15 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
return origin + ":" + G.window.location.port;
}
public async fetchUsersAndOrgs() {
const data = await this.api.getSessionAll();
if (this.isDisposed()) { return; }
bundleChanges(() => {
this.users.set(data.users);
this.orgs.set(data.orgs);
});
}
private async _doInitialize() {
this.appObs.set(null);
try {
@@ -172,15 +185,6 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
AppModelImpl.create(this.appObs, this, null, null, {error: err.message, status: err.status || 500});
}
}
private async _fetchUsersAndOrgs() {
const data = await this.api.getSessionAll();
if (this.isDisposed()) { return; }
bundleChanges(() => {
this.users.set(data.users);
this.orgs.set(data.orgs);
});
}
}
export class AppModelImpl extends Disposable implements AppModel {
@@ -225,13 +229,22 @@ export class AppModelImpl extends Disposable implements AppModel {
public async showUpgradeModal() {
if (this.planName && this.currentOrg) {
buildUpgradeModal(this, this.planName);
if (this.isPersonal) {
this.showNewSiteModal();
} else if (this.isTeamSite) {
buildUpgradeModal(this, this.planName);
} else {
throw new Error("Unexpected state");
}
}
}
public async showNewSiteModal() {
public showNewSiteModal() {
if (this.planName) {
buildNewSiteModal(this, this.planName);
buildNewSiteModal(this, {
planName: this.planName,
onCreate: () => this.topAppModel.fetchUsersAndOrgs().catch(reportError)
});
}
}

View File

@@ -51,10 +51,10 @@ function makePrefFunctions<P extends keyof PrefsTypes>(prefsTypeName: P) {
}
// Functions actually exported are:
// - getUserOrgPrefsObs(appModel): Observsble<UserOrgPrefs>
// - getUserOrgPrefObs(userOrgPrefsObs, prefName): Observsble<PrefType[prefName]>
// - getUserPrefsObs(appModel): Observsble<UserPrefs>
// - getUserPrefObs(userPrefsObs, prefName): Observsble<PrefType[prefName]>
// - getUserOrgPrefsObs(appModel): Observable<UserOrgPrefs>
// - getUserOrgPrefObs(userOrgPrefsObs, prefName): Observable<PrefType[prefName]>
// - getUserPrefsObs(appModel): Observable<UserPrefs>
// - getUserPrefObs(userPrefsObs, prefName): Observable<PrefType[prefName]>
export const {getPrefsObs: getUserOrgPrefsObs, getPrefObs: getUserOrgPrefObs} = makePrefFunctions('userOrgPrefs');
export const {getPrefsObs: getUserPrefsObs, getPrefObs: getUserPrefObs} = makePrefFunctions('userPrefs');

View File

@@ -14,11 +14,12 @@ import { createTopBarHome } from 'app/client/ui/TopBar';
import { cssBreadcrumbs, cssBreadcrumbsLink, separator } from 'app/client/ui2018/breadcrumbs';
import { bigBasicButton, bigBasicButtonLink, bigPrimaryButton } from 'app/client/ui2018/buttons';
import { loadingSpinner } from 'app/client/ui2018/loaders';
import { NEW_DEAL, showTeamUpgradeConfirmation } from 'app/client/ui/ProductUpgrades';
import { IconName } from 'app/client/ui2018/IconList';
import { BillingTask, IBillingCoupon } from 'app/common/BillingAPI';
import { capitalize } from 'app/common/gutil';
import { Organization } from 'app/common/UserAPI';
import { Disposable, dom, DomArg, IAttrObj, makeTestId, Observable } from 'grainjs';
import { IconName } from '../ui2018/IconList';
const testId = makeTestId('test-bp-');
const billingTasksNames = {
@@ -26,6 +27,7 @@ const billingTasksNames = {
signUpLite: 'Complete Sign Up', // task for payment page
updateDomain: 'Update Name', // task for summary page
cancelPlan: 'Cancel plan', // this is not a task, but a sub page
upgraded: 'Account',
};
/**
@@ -41,6 +43,8 @@ export class BillingPage extends Disposable {
constructor(private _appModel: AppModel) {
super();
// TODO: remove once NEW_DEAL is there. Execute for side effect
void NEW_DEAL();
this._appModel.refreshOrgUsage().catch(reportError);
}
@@ -77,7 +81,7 @@ export class BillingPage extends Disposable {
* Builds the contentMain dom for the current billing page.
*/
private _buildCurrentPageDom() {
return css.billingWrapper(
const page = css.billingWrapper(
dom.domComputed(this._model.currentSubpage, (subpage) => {
if (!subpage) {
return this._buildSummaryPage();
@@ -86,6 +90,11 @@ export class BillingPage extends Disposable {
}
})
);
if (this._model.currentTask.get() === 'upgraded') {
urlState().pushUrl({params: {}}, { replace: true }).catch(() => {});
showTeamUpgradeConfirmation(this);
}
return page;
}
private _buildSummaryPage() {

View File

@@ -10,12 +10,12 @@ 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 {buildUpgradeButton} from 'app/client/ui/ProductUpgrades';
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 {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour';
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
import {colors, isNarrowScreenObs} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
@@ -26,12 +26,12 @@ 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} from 'grainjs';
import sortBy = require('lodash/sortBy');
import {computed, Computed, dom, DomArg, DomContents, IDisposableOwner,
makeTestId, observable, Observable} from 'grainjs';
import {buildTemplateDocs} from 'app/client/ui/TemplateDocs';
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import {bigBasicButton} from 'app/client/ui2018/buttons';
import {getUserOrgPrefObs, getUserOrgPrefsObs} from 'app/client/models/UserPrefs';
import sortBy = require('lodash/sortBy');
const testId = makeTestId('test-dm-');
@@ -45,27 +45,14 @@ export function createDocMenu(home: HomeModel) {
return dom.domComputed(home.loading, loading => (
loading === 'slow' ? css.spinner(loadingSpinner()) :
loading ? null :
createLoadedDocMenu(home)
dom.create(createLoadedDocMenu, home)
));
}
function createUpgradeNudge(home: HomeModel) {
const isLoggedIn = !!home.app.currentValidUser;
const isOnFreePersonal = home.app.currentOrg?.billingAccount?.product?.name === 'starter';
const userOrgPrefs = getUserOrgPrefsObs(home.app);
const seenNudge = getUserOrgPrefObs(userOrgPrefs, 'seenFreeTeamUpgradeNudge');
return dom.maybe(use => isLoggedIn && isOnFreePersonal && !use(seenNudge),
() => buildUpgradeNudge({
onClose: () => seenNudge.set(true),
// On show prices, we will clear the nudge in database once there is some free team site created
// The better way is to read all workspaces that this person have and decide then - but this is done
// asynchronously - so we potentially can show this nudge to people that already have team site.
onUpgrade: () => home.app.showUpgradeModal()
}));
}
function createLoadedDocMenu(home: HomeModel) {
function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
const flashDocId = observable<string|null>(null);
const upgradeButton = buildUpgradeButton(owner, home.app);
return css.docList(
showWelcomeQuestions(home.app.userPrefsObs),
css.docMenu(
@@ -83,14 +70,16 @@ function createLoadedDocMenu(home: HomeModel) {
page === 'templates' ? makeLocalViewSettings(home, 'templates') :
workspace ? makeLocalViewSettings(home, workspace.id) :
home;
return [
// Hide the sort option only when showing intro.
((showIntro && page === 'all') ? null :
buildPrefs(viewSettings, {hideSort: showIntro})
((showIntro && page === 'all') ? css.prefSelectors(upgradeButton.showUpgradeButton()) :
// This is float:right element
buildPrefs(viewSettings, {hideSort: showIntro}, upgradeButton.showUpgradeButton())
),
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded or
// 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.docHeaderIconDark('PinBig'), 'Pinned Documents'),
createPinnedDocs(home, home.currentWSPinnedDocs),
@@ -128,7 +117,8 @@ function createLoadedDocMenu(home: HomeModel) {
dom('div',
showIntro ? buildHomeIntro(home) : null,
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
dom.maybe(use => use(isNarrowScreenObs()), () => createUpgradeNudge(home)),
dom.maybe(use => use(isNarrowScreenObs()),
() => upgradeButton.showUpgradeCard()),
shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
) :
(page === 'trash') ?
@@ -155,7 +145,8 @@ function createLoadedDocMenu(home: HomeModel) {
}),
testId('doclist')
),
dom.maybe(use => !use(isNarrowScreenObs()) && use(home.currentPage) === 'all', () => createUpgradeNudge(home)),
dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
() => upgradeButton.showUpgradeCard()),
);
}
@@ -309,7 +300,10 @@ function buildOtherSites(home: HomeModel) {
* If hideSort is true, will hide the sort dropdown: it has no effect on the list of examples, so
* best to hide when those are the only docs shown.
*/
function buildPrefs(viewSettings: ViewSettings, options: {hideSort: boolean}): DomContents {
function buildPrefs(
viewSettings: ViewSettings,
options: {hideSort: boolean},
...args: DomArg<HTMLElement>[]): DomContents {
return css.prefSelectors(
// The Sort selector.
options.hideSort ? null : dom.update(
@@ -330,6 +324,7 @@ function buildPrefs(viewSettings: ViewSettings, options: {hideSort: boolean}): D
cssButtonSelect.cls("-light"),
testId('view-mode')
),
...args
);
}

View File

@@ -1,15 +1,11 @@
import type {AppModel} from 'app/client/models/AppModel';
import {commonUrls} from 'app/common/gristUrls';
import {Disposable} from 'grainjs';
import {Disposable, DomContents, IDisposableOwner, Observable, observable} from 'grainjs';
export function buildUpgradeNudge(options: {
onClose: () => void;
onUpgrade: () => void
export function buildNewSiteModal(context: Disposable, options: {
planName: string,
onCreate?: () => void
}) {
return null;
}
export function buildNewSiteModal(owner: Disposable, current: string | null) {
window.location.href = commonUrls.plans;
}
@@ -17,11 +13,21 @@ export function buildUpgradeModal(owner: Disposable, planName: string) {
window.location.href = commonUrls.plans;
}
export class UpgradeButton extends Disposable {
constructor(appModel: AppModel) {
super();
}
public buildDom() {
return null;
}
export function showTeamUpgradeConfirmation(owner: Disposable) {
}
export interface UpgradeButton {
showUpgradeCard(): DomContents;
showUpgradeButton(): DomContents;
}
export function buildUpgradeButton(owner: IDisposableOwner, app: AppModel): UpgradeButton {
return {
showUpgradeCard : () => null,
showUpgradeButton : () => null,
};
}
export function NEW_DEAL(): Observable<boolean> {
return observable(false);
}

View File

@@ -58,6 +58,7 @@ export type IconName = "ChartArea" |
"Feedback" |
"Filter" |
"FilterSimple" |
"Fireworks" |
"Folder" |
"FontBold" |
"FontItalic" |
@@ -89,6 +90,7 @@ export type IconName = "ChartArea" |
"Pivot" |
"Plus" |
"Public" |
"PublicColor" |
"PublicFilled" |
"Redo" |
"Remove" |
@@ -181,6 +183,7 @@ export const IconList: IconName[] = ["ChartArea",
"Feedback",
"Filter",
"FilterSimple",
"Fireworks",
"Folder",
"FontBold",
"FontItalic",
@@ -212,6 +215,7 @@ export const IconList: IconName[] = ["ChartArea",
"Pivot",
"Plus",
"Public",
"PublicColor",
"PublicFilled",
"Redo",
"Remove",