mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Customizable stripe plans.
Summary: - Reading plans from Stripe, and allowing Stripe to define custom plans. - Storing product features (aka limits) in Stripe, that override those in db. - Adding hierarchical data in Stripe. All features are defined at Product level but can be overwritten on Price levels. - New options for Support user to -- Override product for team site (if he is added as a billing manager) -- Override subscription and customer id for a team site -- Attach an "offer", an custom plan configured in stripe that a team site can use -- Enabling wire transfer for subscription by allowing subscription to be created without a payment method (which is customizable) Test Plan: Updated and new. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4201
This commit is contained in:
@@ -35,6 +35,7 @@ export class DocumentUsage extends Disposable {
|
||||
private readonly _currentDocUsage = this._docPageModel.currentDocUsage;
|
||||
private readonly _currentOrg = this._docPageModel.currentOrg;
|
||||
private readonly _currentProduct = this._docPageModel.currentProduct;
|
||||
private readonly _currentFeatures = this._docPageModel.currentFeatures;
|
||||
|
||||
// TODO: Update this whenever the rest of the UI is internationalized.
|
||||
private readonly _rowCountFormatter = new Intl.NumberFormat('en-US');
|
||||
@@ -56,8 +57,8 @@ export class DocumentUsage extends Disposable {
|
||||
});
|
||||
|
||||
private readonly _rowMetricOptions: Computed<MetricOptions> =
|
||||
Computed.create(this, this._currentProduct, this._rowCount, (_use, product, rowCount) => {
|
||||
const maxRows = product?.features.baseMaxRowsPerDocument;
|
||||
Computed.create(this, this._currentFeatures, this._rowCount, (_use, features, rowCount) => {
|
||||
const maxRows = features?.baseMaxRowsPerDocument;
|
||||
// Invalid row limits are currently treated as if they are undefined.
|
||||
const maxValue = maxRows && maxRows > 0 ? maxRows : undefined;
|
||||
return {
|
||||
@@ -71,8 +72,8 @@ export class DocumentUsage extends Disposable {
|
||||
});
|
||||
|
||||
private readonly _dataSizeMetricOptions: Computed<MetricOptions> =
|
||||
Computed.create(this, this._currentProduct, this._dataSizeBytes, (_use, product, dataSize) => {
|
||||
const maxSize = product?.features.baseMaxDataSizePerDocument;
|
||||
Computed.create(this, this._currentFeatures, this._dataSizeBytes, (_use, features, dataSize) => {
|
||||
const maxSize = features?.baseMaxDataSizePerDocument;
|
||||
// Invalid data size limits are currently treated as if they are undefined.
|
||||
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
|
||||
return {
|
||||
@@ -93,8 +94,8 @@ export class DocumentUsage extends Disposable {
|
||||
});
|
||||
|
||||
private readonly _attachmentsSizeMetricOptions: Computed<MetricOptions> =
|
||||
Computed.create(this, this._currentProduct, this._attachmentsSizeBytes, (_use, product, attachmentsSize) => {
|
||||
const maxSize = product?.features.baseMaxAttachmentsBytesPerDocument;
|
||||
Computed.create(this, this._currentFeatures, this._attachmentsSizeBytes, (_use, features, attachmentsSize) => {
|
||||
const maxSize = features?.baseMaxAttachmentsBytesPerDocument;
|
||||
// Invalid attachments size limits are currently treated as if they are undefined.
|
||||
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
|
||||
return {
|
||||
@@ -156,11 +157,12 @@ export class DocumentUsage extends Disposable {
|
||||
|
||||
const org = use(this._currentOrg);
|
||||
const product = use(this._currentProduct);
|
||||
const features = use(this._currentFeatures);
|
||||
const status = use(this._dataLimitStatus);
|
||||
if (!org || !status) { return null; }
|
||||
|
||||
return buildMessage([
|
||||
buildLimitStatusMessage(status, product?.features, {
|
||||
buildLimitStatusMessage(status, features, {
|
||||
disableRawDataLink: true
|
||||
}),
|
||||
(product && isFreePlan(product.name)
|
||||
@@ -195,7 +197,7 @@ export class DocumentUsage extends Disposable {
|
||||
|
||||
export function buildLimitStatusMessage(
|
||||
status: NonNullable<DataLimitStatus>,
|
||||
features?: Features,
|
||||
features?: Features|null,
|
||||
options: {
|
||||
disableRawDataLink?: boolean;
|
||||
} = {}
|
||||
|
||||
@@ -12,9 +12,10 @@ import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrade
|
||||
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
|
||||
import {gristThemePrefs} from 'app/client/ui2018/theme';
|
||||
import {AsyncCreate} from 'app/common/AsyncCreate';
|
||||
import {PlanSelection} from 'app/common/BillingAPI';
|
||||
import {ICustomWidget} from 'app/common/CustomWidget';
|
||||
import {OrgUsageSummary} from 'app/common/DocUsage';
|
||||
import {Features, isLegacyPlan, Product} from 'app/common/Features';
|
||||
import {Features, isFreePlan, isLegacyPlan, mergedFeatures, Product} from 'app/common/Features';
|
||||
import {GristLoadConfig, IGristUrlState} from 'app/common/gristUrls';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import {LocalPlugin} from 'app/common/plugin';
|
||||
@@ -112,7 +113,8 @@ export interface AppModel {
|
||||
lastVisitedOrgDomain: Observable<string|null>;
|
||||
|
||||
currentProduct: Product|null; // The current org's product.
|
||||
currentFeatures: Features; // Features of the current org's product.
|
||||
currentPriceId: string|null; // The current org's stripe plan id.
|
||||
currentFeatures: Features|null; // Features of the current org's product.
|
||||
|
||||
userPrefsObs: Observable<UserPrefs>;
|
||||
themePrefs: Observable<ThemePrefs>;
|
||||
@@ -133,8 +135,8 @@ export interface AppModel {
|
||||
supportGristNudge: SupportGristNudge;
|
||||
|
||||
refreshOrgUsage(): Promise<void>;
|
||||
showUpgradeModal(): void;
|
||||
showNewSiteModal(): void;
|
||||
showUpgradeModal(): Promise<void>;
|
||||
showNewSiteModal(): Promise<void>;
|
||||
isBillingManager(): boolean; // If user is a billing manager for this org
|
||||
isSupport(): boolean; // If user is a Support user
|
||||
isOwner(): boolean; // If user is an owner of this org
|
||||
@@ -142,6 +144,7 @@ export interface AppModel {
|
||||
isInstallAdmin(): boolean; // Is user an admin of this installation
|
||||
dismissPopup(name: DismissedPopup, isSeen: boolean): void; // Mark popup as dismissed or not.
|
||||
switchUser(user: FullUser, org?: string): Promise<void>;
|
||||
isFreePlan(): boolean;
|
||||
}
|
||||
|
||||
export interface TopAppModelOptions {
|
||||
@@ -293,7 +296,11 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
public readonly lastVisitedOrgDomain = this.autoDispose(sessionStorageObs('grist-last-visited-org-domain'));
|
||||
|
||||
public readonly currentProduct = this.currentOrg?.billingAccount?.product ?? null;
|
||||
public readonly currentFeatures = this.currentProduct?.features ?? {};
|
||||
public readonly currentPriceId = this.currentOrg?.billingAccount?.stripePlanId ?? null;
|
||||
public readonly currentFeatures = mergedFeatures(
|
||||
this.currentProduct?.features ?? null,
|
||||
this.currentOrg?.billingAccount?.features ?? null
|
||||
);
|
||||
|
||||
public readonly isPersonal = Boolean(this.currentOrg?.owner);
|
||||
public readonly isTeamSite = Boolean(this.currentOrg) && !this.isPersonal;
|
||||
@@ -367,7 +374,17 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
if (state.createTeam) {
|
||||
// Remove params from the URL.
|
||||
urlState().pushUrl({createTeam: false, params: {}}, {avoidReload: true, replace: true}).catch(() => {});
|
||||
this.showNewSiteModal(state.params?.planType);
|
||||
this.showNewSiteModal({
|
||||
priceId: state.params?.billingPlan,
|
||||
product: state.params?.planType,
|
||||
}).catch(reportError);
|
||||
} else if (state.upgradeTeam) {
|
||||
// Remove params from the URL.
|
||||
urlState().pushUrl({upgradeTeam: false, params: {}}, {avoidReload: true, replace: true}).catch(() => {});
|
||||
this.showUpgradeModal({
|
||||
priceId: state.params?.billingPlan,
|
||||
product: state.params?.planType,
|
||||
}).catch(reportError);
|
||||
}
|
||||
|
||||
G.window.resetDismissedPopups = (seen = false) => {
|
||||
@@ -384,23 +401,28 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
return this.currentProduct?.name ?? null;
|
||||
}
|
||||
|
||||
public async showUpgradeModal() {
|
||||
public async showUpgradeModal(plan?: PlanSelection) {
|
||||
if (this.planName && this.currentOrg) {
|
||||
if (this.isPersonal) {
|
||||
this.showNewSiteModal();
|
||||
await this.showNewSiteModal(plan);
|
||||
} else if (this.isTeamSite) {
|
||||
buildUpgradeModal(this, this.planName);
|
||||
await buildUpgradeModal(this, {
|
||||
appModel: this,
|
||||
pickPlan: plan,
|
||||
reason: 'upgrade'
|
||||
});
|
||||
} else {
|
||||
throw new Error("Unexpected state");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public showNewSiteModal(selectedPlan?: string) {
|
||||
|
||||
public async showNewSiteModal(plan?: PlanSelection) {
|
||||
if (this.planName) {
|
||||
buildNewSiteModal(this, {
|
||||
planName: this.planName,
|
||||
selectedPlan,
|
||||
await buildNewSiteModal(this, {
|
||||
appModel: this,
|
||||
plan,
|
||||
onCreate: () => this.topAppModel.fetchUsersAndOrgs().catch(reportError)
|
||||
});
|
||||
}
|
||||
@@ -451,6 +473,10 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
this.lastVisitedOrgDomain.set(null);
|
||||
}
|
||||
|
||||
public isFreePlan() {
|
||||
return isFreePlan(this.planName || '');
|
||||
}
|
||||
|
||||
private _updateLastVisitedOrgDomain({doc, org}: IGristUrlState, availableOrgs: Organization[]) {
|
||||
if (
|
||||
!org ||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
|
||||
import {delay} from 'app/common/delay';
|
||||
import {OpenDocMode, OpenDocOptions, UserOverride} from 'app/common/DocListAPI';
|
||||
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
|
||||
import {Product} from 'app/common/Features';
|
||||
import {Features, mergedFeatures, Product} from 'app/common/Features';
|
||||
import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
|
||||
import {getReconnectTimeout} from 'app/common/gutil';
|
||||
import {canEdit, isOwner} from 'app/common/roles';
|
||||
@@ -61,6 +61,10 @@ export interface DocPageModel {
|
||||
* changes, or a doc usage message is received from the server.
|
||||
*/
|
||||
currentProduct: Observable<Product|null>;
|
||||
/**
|
||||
* Current features of the product
|
||||
*/
|
||||
currentFeatures: Observable<Features|null>;
|
||||
|
||||
// This block is to satisfy previous interface, but usable as this.currentDoc.get().id, etc.
|
||||
currentDocId: Observable<string|undefined>;
|
||||
@@ -116,6 +120,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
* changes, or a doc usage message is received from the server.
|
||||
*/
|
||||
public readonly currentProduct = Observable.create<Product|null>(this, null);
|
||||
public readonly currentFeatures: Computed<Features|null>;
|
||||
|
||||
public readonly currentUrlId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.urlId : undefined);
|
||||
public readonly currentDocId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.id : undefined);
|
||||
@@ -169,6 +174,14 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
constructor(private _appObj: App, public readonly appModel: AppModel, private _api: UserAPI = appModel.api) {
|
||||
super();
|
||||
|
||||
this.currentFeatures = Computed.create(this, use => {
|
||||
const product = use(this.currentProduct);
|
||||
if (!product) { return null; }
|
||||
const ba = use(this.currentOrg)?.billingAccount?.features ?? {};
|
||||
const merged = mergedFeatures(product.features, ba);
|
||||
return merged;
|
||||
});
|
||||
|
||||
this.autoDispose(subscribe(urlState().state, (use, state) => {
|
||||
const urlId = state.doc;
|
||||
const urlOpenMode = state.mode;
|
||||
|
||||
@@ -382,7 +382,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
|
||||
|
||||
// Check if active product allows just a single workspace.
|
||||
function _isSingleWorkspaceMode(app: AppModel): boolean {
|
||||
return app.currentFeatures.maxWorkspacesPerOrg === 1;
|
||||
return app.currentFeatures?.maxWorkspacesPerOrg === 1;
|
||||
}
|
||||
|
||||
// Returns a default view mode preference. We used to show 'list' for everyone. We now default to
|
||||
|
||||
@@ -180,9 +180,9 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
||||
) {
|
||||
super();
|
||||
if (this._options.appModel) {
|
||||
const product = this._options.appModel.currentProduct;
|
||||
const features = this._options.appModel.currentFeatures;
|
||||
const {supportEmail} = getGristConfig();
|
||||
this._shareAnnotator = new ShareAnnotator(product, initData, {supportEmail});
|
||||
this._shareAnnotator = new ShareAnnotator(features, initData, {supportEmail});
|
||||
}
|
||||
this.annotate();
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export class AccountWidget extends Disposable {
|
||||
|
||||
// Show 'Organization Settings' when on a home page of a valid org.
|
||||
(!this._docPageModel && currentOrg && this._appModel.isTeamSite ?
|
||||
menuItem(() => manageTeamUsers(currentOrg, user, this._appModel.api),
|
||||
menuItem(() => manageTeamUsers({org: currentOrg, user, api: this._appModel.api}),
|
||||
roles.canEditAccess(currentOrg.access) ? t("Manage Team") : t("Access Details"),
|
||||
testId('dm-org-access')) :
|
||||
// Don't show on doc pages, or for personal orgs.
|
||||
|
||||
@@ -117,7 +117,7 @@ export class AppHeader extends Disposable {
|
||||
|
||||
// Show 'Organization Settings' when on a home page of a valid org.
|
||||
(!this._docPageModel && this._currentOrg && !this._currentOrg.owner ?
|
||||
menuItem(() => manageTeamUsersApp(this._appModel),
|
||||
menuItem(() => manageTeamUsersApp({app: this._appModel}),
|
||||
'Manage Team', testId('orgmenu-manage-team'),
|
||||
dom.cls('disabled', !roles.canEditAccess(this._currentOrg.access))) :
|
||||
// Don't show on doc pages, or for personal orgs.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {IModalControl, modal} from 'app/client/ui2018/modals';
|
||||
import {TEAM_PLAN} from 'app/common/Features';
|
||||
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
|
||||
import {UserAPIImpl} from 'app/common/UserAPI';
|
||||
import {PlanSelection} from 'app/common/BillingAPI';
|
||||
import {
|
||||
Disposable, dom, DomArg, DomContents, DomElementArg, IDisposableOwner, input, makeTestId,
|
||||
Observable, styled
|
||||
@@ -19,9 +20,9 @@ import { makeT } from '../lib/localization';
|
||||
const t = makeT('CreateTeamModal');
|
||||
const testId = makeTestId('test-create-team-');
|
||||
|
||||
export function buildNewSiteModal(context: Disposable, options: {
|
||||
planName: string,
|
||||
selectedPlan?: string,
|
||||
export async function buildNewSiteModal(context: Disposable, options: {
|
||||
appModel: AppModel,
|
||||
plan?: PlanSelection,
|
||||
onCreate?: () => void
|
||||
}) {
|
||||
const { onCreate } = options;
|
||||
@@ -78,7 +79,11 @@ class NewSiteModalContent extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUpgradeModal(owner: Disposable, planName: string): void {
|
||||
export function buildUpgradeModal(owner: Disposable, options: {
|
||||
appModel: AppModel,
|
||||
pickPlan?: PlanSelection,
|
||||
reason?: 'upgrade' | 'renew',
|
||||
}): Promise<void> {
|
||||
throw new UserError(t(`Billing is not supported in grist-core`));
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
css.docMenu(
|
||||
attachAddNewTip(home),
|
||||
|
||||
dom.maybe(!home.app.currentFeatures.workspaces, () => [
|
||||
dom.maybe(!home.app.currentFeatures?.workspaces, () => [
|
||||
css.docListHeader(t("This service is not available right now")),
|
||||
dom('span', t("(The organization needs a paid plan)")),
|
||||
]),
|
||||
|
||||
@@ -168,7 +168,7 @@ function buildButtons(homeModel: HomeModel, options: {
|
||||
!options.invite ? null :
|
||||
cssBtn(cssBtnIcon('Help'), t("Invite Team Members"), testId('intro-invite'),
|
||||
cssButton.cls('-primary'),
|
||||
dom.on('click', () => manageTeamUsersApp(homeModel.app)),
|
||||
dom.on('click', () => manageTeamUsersApp({app: homeModel.app})),
|
||||
),
|
||||
!options.templates ? null :
|
||||
cssBtn(cssBtnIcon('FieldTable'), t("Browse Templates"), testId('intro-templates'),
|
||||
|
||||
@@ -196,7 +196,7 @@ export async function importFromPluginAndOpen(home: HomeModel, source: ImportSou
|
||||
function addMenu(home: HomeModel, creating: Observable<boolean>): DomElementArg[] {
|
||||
const org = home.app.currentOrg;
|
||||
const orgAccess: roles.Role|null = org ? org.access : null;
|
||||
const needUpgrade = home.app.currentFeatures.maxWorkspacesPerOrg === 1;
|
||||
const needUpgrade = home.app.currentFeatures?.maxWorkspacesPerOrg === 1;
|
||||
|
||||
return [
|
||||
menuItem(() => createDocAndOpen(home), menuIcon('Page'), t("Create Empty Document"),
|
||||
@@ -261,7 +261,7 @@ function workspaceMenu(home: HomeModel, ws: Workspace, renaming: Observable<Work
|
||||
});
|
||||
}
|
||||
|
||||
const needUpgrade = home.app.currentFeatures.maxWorkspacesPerOrg === 1;
|
||||
const needUpgrade = home.app.currentFeatures?.maxWorkspacesPerOrg === 1;
|
||||
|
||||
return [
|
||||
upgradableMenuItem(needUpgrade, () => renaming.set(ws), t("Rename"),
|
||||
|
||||
@@ -140,8 +140,8 @@ class SaveCopyModal extends Disposable {
|
||||
}
|
||||
// We won't have info about any other org except the one we are at.
|
||||
if (org.id === this._app.currentOrg?.id) {
|
||||
const workspaces = this._app.currentOrg.billingAccount?.product.features.workspaces ?? true;
|
||||
const numberAllowed = this._app.currentOrg.billingAccount?.product.features.maxWorkspacesPerOrg ?? 2;
|
||||
const workspaces = this._app.currentFeatures?.workspaces ?? true;
|
||||
const numberAllowed = this._app.currentFeatures?.maxWorkspacesPerOrg ?? 2;
|
||||
return workspaces && numberAllowed > 1;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -2,20 +2,33 @@ import {loadUserManager} from 'app/client/lib/imports';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {FullUser, Organization, UserAPI} from 'app/common/UserAPI';
|
||||
|
||||
export interface ManageTeamUsersOptions {
|
||||
org: Organization;
|
||||
user: FullUser | null;
|
||||
api: UserAPI;
|
||||
onSave?: (personal: boolean) => Promise<unknown>;
|
||||
}
|
||||
|
||||
// Opens the user-manager for the given org.
|
||||
export async function manageTeamUsers(org: Organization, user: FullUser|null, api: UserAPI) {
|
||||
export async function manageTeamUsers({org, user, api, onSave}: ManageTeamUsersOptions) {
|
||||
(await loadUserManager()).showUserManagerModal(api, {
|
||||
permissionData: api.getOrgAccess(org.id),
|
||||
activeUser: user,
|
||||
resourceType: 'organization',
|
||||
resourceId: org.id,
|
||||
resource: org,
|
||||
onSave
|
||||
});
|
||||
}
|
||||
|
||||
export interface ManagePersonalUsersAppOptions {
|
||||
app: AppModel;
|
||||
onSave?: (personal: boolean) => Promise<unknown>;
|
||||
}
|
||||
|
||||
// Opens the user-manager for the current org in the given AppModel.
|
||||
export async function manageTeamUsersApp(app: AppModel) {
|
||||
export async function manageTeamUsersApp({app, onSave}: ManagePersonalUsersAppOptions) {
|
||||
if (app.currentOrg) {
|
||||
return manageTeamUsers(app.currentOrg, app.currentValidUser, app.api);
|
||||
return manageTeamUsers({org: app.currentOrg, user: app.currentValidUser, api: app.api, onSave});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, style
|
||||
|
||||
const t = makeT('TopBar');
|
||||
|
||||
export function createTopBarHome(appModel: AppModel) {
|
||||
export function createTopBarHome(appModel: AppModel, onSave?: (personal: boolean) => Promise<unknown>){
|
||||
const isAnonymous = !appModel.currentValidUser;
|
||||
|
||||
return [
|
||||
@@ -32,7 +32,7 @@ export function createTopBarHome(appModel: AppModel) {
|
||||
[
|
||||
basicButton(
|
||||
t("Manage Team"),
|
||||
dom.on('click', () => manageTeamUsersApp(appModel)),
|
||||
dom.on('click', () => manageTeamUsersApp({app: appModel, onSave})),
|
||||
testId('topbar-manage-team')
|
||||
),
|
||||
cssSpacer()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {DomArg, keyframes, styled} from 'grainjs';
|
||||
import {DomArg, keyframes, Observable, observable, styled} from 'grainjs';
|
||||
|
||||
const rotate360 = keyframes(`
|
||||
from { transform: rotate(45deg); }
|
||||
@@ -42,6 +42,21 @@ export function loadingDots(...args: DomArg<HTMLDivElement>[]) {
|
||||
);
|
||||
}
|
||||
|
||||
export function watchPromise<T extends (...args: any[]) => any>(fun: T): T & {busy: Observable<boolean>} {
|
||||
const loading = observable(false);
|
||||
const result = async (...args: any) => {
|
||||
loading.set(true);
|
||||
try {
|
||||
return await fun(...args);
|
||||
} finally {
|
||||
if (!loading.isDisposed()) {
|
||||
loading.set(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
return Object.assign(result, {busy: loading}) as any;
|
||||
}
|
||||
|
||||
const cssLoadingDotsContainer = styled('div', `
|
||||
--dot-size: 10px;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -416,8 +416,8 @@ export function confirmModal(
|
||||
*/
|
||||
export function promptModal(
|
||||
title: string,
|
||||
onConfirm: (text: string) => Promise<unknown>,
|
||||
btnText: string,
|
||||
onConfirm: (text: string) => Promise<void>,
|
||||
btnText?: string,
|
||||
initial?: string,
|
||||
placeholder?: string,
|
||||
onCancel?: () => void
|
||||
@@ -429,7 +429,7 @@ export function promptModal(
|
||||
const options: ISaveModalOptions = {
|
||||
title,
|
||||
body: txtInput,
|
||||
saveLabel: btnText,
|
||||
saveLabel: btnText || t('Save'),
|
||||
saveFunc: () => {
|
||||
// Mark that confirm was invoked.
|
||||
confirmed = true;
|
||||
|
||||
@@ -381,7 +381,7 @@ export class FormulaAssistant extends Disposable {
|
||||
canUpgradeSite ? t('upgrade to the Pro Team plan') : t('upgrade your plan'),
|
||||
dom.on('click', async () => {
|
||||
if (canUpgradeSite) {
|
||||
this._gristDoc.appModel.showUpgradeModal();
|
||||
this._gristDoc.appModel.showUpgradeModal().catch(reportError);
|
||||
} else {
|
||||
await urlState().pushUrl({billing: 'billing'});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user