mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user