(core) Show usage banners in doc menu of free team sites

Summary:
Also fixes a minor CSS regression in UserManager where the
link to add a team member wasn't shown on a separate row.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3444
This commit is contained in:
George Gevoian
2022-05-25 23:47:26 -07:00
parent 2f3cf59fc3
commit 74ec9358da
16 changed files with 341 additions and 213 deletions

View File

@@ -0,0 +1,165 @@
import {colors, isNarrowScreenObs} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {Disposable, dom, DomArg, DomElementArg, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-banner-');
export interface BannerOptions {
/**
* Content to display in the banner.
*/
content: DomArg;
/**
* The banner style.
*
* Warning banners have a yellow background. Error banners have a red
* background.
*/
style: 'warning' | 'error';
/**
* Optional variant of `content` to display when screen width becomes narrow.
*/
contentSmall?: DomArg;
/**
* Whether a button to close the banner should be shown.
*
* If true, `onClose` should also be specified; it will be called when the close
* button is clicked.
*
* Defaults to false.
*/
showCloseButton?: boolean;
/**
* Whether a button to collapse/expand the banner should be shown on narrow screens.
*
* Defaults to false.
*/
showExpandButton?: boolean;
/**
* Function that is called when the banner close button is clicked.
*
* Should be used to handle disposal of the Banner.
*/
onClose?(): void;
}
/**
* A customizable banner for displaying at the top of a page.
*/
export class Banner extends Disposable {
private readonly _isExpanded = Observable.create(this, true);
constructor(private _options: BannerOptions) {
super();
}
public buildDom() {
return cssBanner(
cssBanner.cls(`-${this._options.style}`),
this._buildContent(),
this._buildButtons(),
);
}
private _buildContent() {
const {content, contentSmall} = this._options;
return dom.domComputed(use => {
if (contentSmall === undefined) { return [content]; }
const isExpanded = use(this._isExpanded);
const isNarrowScreen = use(isNarrowScreenObs());
return [isNarrowScreen && !isExpanded ? contentSmall : content];
});
}
private _buildButtons() {
return cssButtons(
this._options.showExpandButton ? this._buildExpandButton() : null,
this._options.showCloseButton ? this._buildCloseButton() : null,
);
}
private _buildCloseButton() {
return cssButton('CrossBig',
dom.on('click', () => this._options.onClose?.()),
testId('close'),
);
}
private _buildExpandButton() {
return dom.maybe(isNarrowScreenObs(), () => {
return cssExpandButton('Dropdown',
cssExpandButton.cls('-expanded', this._isExpanded),
dom.on('click', () => this._isExpanded.set(!this._isExpanded.get())),
);
});
}
}
export function buildBannerMessage(...domArgs: DomElementArg[]) {
return cssBannerMessage(
cssIcon('Idea'),
cssLightlyBoldedText(domArgs),
);
}
const cssBanner = styled('div', `
display: flex;
padding: 10px;
gap: 16px;
color: white;
&-warning {
background: #E6A117;
}
&-error {
background: ${colors.error};
}
`);
const cssButtons = styled('div', `
display: flex;
gap: 16px;
flex-shrink: 0;
margin-left: auto;
`);
const cssButton = styled(icon, `
width: 16px;
height: 16px;
cursor: pointer;
background-color: white;
`);
const cssExpandButton = styled(cssButton, `
&-expanded {
-webkit-mask-image: var(--icon-DropdownUp);
}
`);
const cssLightlyBoldedText = styled('div', `
font-weight: 500;
`);
const cssIconAndText = styled('div', `
display: flex;
gap: 16px;
`);
const cssBannerMessage = styled(cssIconAndText, `
flex-grow: 1;
justify-content: center;
`);
const cssIcon = styled(icon, `
flex-shrink: 0;
width: 16px;
height: 16px;
background-color: white;
`);

View File

@@ -1,171 +0,0 @@
import {buildLimitStatusMessage, buildUpgradeMessage} from 'app/client/components/DocumentUsage';
import {sessionStorageBoolObs} from 'app/client/lib/localStorageObs';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {colors, isNarrowScreenObs} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {Computed, Disposable, dom, DomComputed, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-doc-usage-banner-');
export class DocUsageBanner extends Disposable {
// Whether the banner is vertically expanded on narrow screens.
private readonly _isExpanded = Observable.create(this, true);
private readonly _currentDocId = this._docPageModel.currentDocId;
private readonly _currentDocUsage = this._docPageModel.currentDocUsage;
private readonly _currentOrg = this._docPageModel.currentOrg;
private readonly _dataLimitStatus = Computed.create(this, this._currentDocUsage, (_use, usage) => {
return usage?.dataLimitStatus ?? null;
});
private readonly _shouldShowBanner: Computed<boolean> =
Computed.create(this, this._currentOrg, (_use, org) => {
return org?.access !== 'guests' && org?.access !== null;
});
// Session storage observable. Set to false to dismiss the banner for the session.
private _showApproachingLimitBannerPref: Observable<boolean>;
constructor(private _docPageModel: DocPageModel) {
super();
this.autoDispose(this._currentDocId.addListener((docId) => {
if (this._showApproachingLimitBannerPref?.isDisposed() === false) {
this._showApproachingLimitBannerPref.dispose();
}
const userId = this._docPageModel.appModel.currentUser?.id ?? 0;
this._showApproachingLimitBannerPref = sessionStorageBoolObs(
`u=${userId}:doc=${docId}:showApproachingLimitBanner`,
true,
);
}));
}
public buildDom() {
return dom.maybe(this._dataLimitStatus, (status): DomComputed => {
switch (status) {
case 'approachingLimit': { return this._buildApproachingLimitBanner(); }
case 'gracePeriod':
case 'deleteOnly': { return this._buildExceedingLimitBanner(status === 'deleteOnly'); }
}
});
}
private _buildApproachingLimitBanner() {
return dom.maybe(this._shouldShowBanner, () => {
return dom.domComputed(use => {
if (!use(this._showApproachingLimitBannerPref)) {
return null;
}
const org = use(this._currentOrg);
if (!org) { return null; }
const features = org.billingAccount?.product.features;
return cssApproachingLimitBanner(
cssBannerMessage(
cssWhiteIcon('Idea'),
cssLightlyBoldedText(
buildLimitStatusMessage('approachingLimit', features),
' ',
buildUpgradeMessage(org.access === 'owners'),
testId('text'),
),
),
cssCloseButton('CrossBig',
dom.on('click', () => this._showApproachingLimitBannerPref.set(false)),
testId('close'),
),
testId('container'),
);
});
});
}
private _buildExceedingLimitBanner(isDeleteOnly: boolean) {
return dom.maybe(this._shouldShowBanner, () => {
return dom.maybe(this._currentOrg, org => {
const features = org.billingAccount?.product.features;
return cssExceedingLimitBanner(
cssBannerMessage(
cssWhiteIcon('Idea'),
cssLightlyBoldedText(
dom.domComputed(use => {
const isExpanded = use(this._isExpanded);
const isNarrowScreen = use(isNarrowScreenObs());
const isOwner = org.access === 'owners';
if (isNarrowScreen && !isExpanded) {
return buildUpgradeMessage(isOwner, 'short');
}
return [
buildLimitStatusMessage(isDeleteOnly ? 'deleteOnly' : 'gracePeriod', features),
' ',
buildUpgradeMessage(isOwner),
];
}),
testId('text'),
),
),
dom.maybe(isNarrowScreenObs(), () => {
return dom.domComputed(this._isExpanded, isExpanded =>
cssExpandButton(
isExpanded ? 'DropdownUp' : 'Dropdown',
dom.on('click', () => this._isExpanded.set(!isExpanded)),
),
);
}),
testId('container'),
);
});
});
}
}
const cssLightlyBoldedText = styled('div', `
font-weight: 500;
`);
const cssUsageBanner = styled('div', `
display: flex;
align-items: flex-start;
padding: 10px;
color: white;
gap: 16px;
`);
const cssApproachingLimitBanner = styled(cssUsageBanner, `
background: #E6A117;
`);
const cssExceedingLimitBanner = styled(cssUsageBanner, `
background: ${colors.error};
`);
const cssIconAndText = styled('div', `
display: flex;
gap: 16px;
`);
const cssBannerMessage = styled(cssIconAndText, `
flex-grow: 1;
justify-content: center;
`);
const cssIcon = styled(icon, `
flex-shrink: 0;
width: 16px;
height: 16px;
`);
const cssWhiteIcon = styled(cssIcon, `
background-color: white;
`);
const cssCloseButton = styled(cssIcon, `
flex-shrink: 0;
cursor: pointer;
background-color: white;
`);
const cssExpandButton = cssCloseButton;

View File

@@ -6,9 +6,10 @@ import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/DocUsage';
import {Features} from 'app/common/Features';
import {Features, isFreeProduct} from 'app/common/Features';
import {commonUrls} from 'app/common/gristUrls';
import {capitalizeFirstWord} from 'app/common/gutil';
import {canUpgradeOrg} from 'app/common/roles';
import {Computed, Disposable, dom, DomContents, DomElementArg, makeTestId, styled} from 'grainjs';
const testId = makeTestId('test-doc-usage-');
@@ -156,12 +157,15 @@ export class DocumentUsage extends Disposable {
const status = use(this._dataLimitStatus);
if (!org || !status) { return null; }
const product = org.billingAccount?.product;
return buildMessage([
buildLimitStatusMessage(status, org.billingAccount?.product.features, {
buildLimitStatusMessage(status, product?.features, {
disableRawDataLink: true
}),
' ',
buildUpgradeMessage(org.access === 'owners')
(product && isFreeProduct(product)
? [' ', buildUpgradeMessage(canUpgradeOrg(org))]
: null
),
]);
});
}
@@ -226,8 +230,8 @@ export function buildLimitStatusMessage(
}
}
export function buildUpgradeMessage(isOwner: boolean, variant: 'short' | 'long' = 'long') {
if (!isOwner) { return 'Contact the site owner to upgrade the plan to raise limits.'; }
export function buildUpgradeMessage(canUpgrade: boolean, variant: 'short' | 'long' = 'long') {
if (!canUpgrade) { return 'Contact the site owner to upgrade the plan to raise limits.'; }
const upgradeLinkText = 'start your 30-day free trial of the Pro plan.';
return [

View File

@@ -0,0 +1,93 @@
import {Banner, buildBannerMessage} from 'app/client/components/Banner';
import {buildUpgradeMessage} from 'app/client/components/DocumentUsage';
import {sessionStorageBoolObs} from 'app/client/lib/localStorageObs';
import {HomeModel} from 'app/client/models/HomeModel';
import {isFreeProduct} from 'app/common/Features';
import {isOwner} from 'app/common/roles';
import {Disposable, dom, makeTestId, Observable} from 'grainjs';
const testId = makeTestId('test-site-usage-banner-');
export class SiteUsageBanner extends Disposable {
private readonly _currentOrg = this._homeModel.app.currentOrg;
private readonly _currentOrgUsage = this._homeModel.currentOrgUsage;
private readonly _product = this._currentOrg?.billingAccount?.product;
private readonly _currentUser = this._homeModel.app.currentValidUser;
// Session storage observable. Set to false to dismiss the banner for the session.
private _showApproachingLimitBannerPref?: Observable<boolean>;
constructor(private _homeModel: HomeModel) {
super();
if (this._currentUser && isOwner(this._currentOrg)) {
this._showApproachingLimitBannerPref = this.autoDispose(sessionStorageBoolObs(
`u=${this._currentUser.id}:org=${this._currentOrg.id}:showApproachingLimitBanner`,
true,
));
}
}
public buildDom() {
return dom.maybe(this._currentOrgUsage, (usage) => {
const {approachingLimit, gracePeriod, deleteOnly} = usage;
if (deleteOnly > 0 || gracePeriod > 0) {
return this._buildExceedingLimitsBanner(deleteOnly + gracePeriod);
} else if (approachingLimit > 0) {
return this._buildApproachingLimitsBanner(approachingLimit);
} else {
return null;
}
});
}
private _buildApproachingLimitsBanner(numDocs: number) {
return dom.domComputed(use => {
if (this._showApproachingLimitBannerPref && !use(this._showApproachingLimitBannerPref)) {
return null;
}
const limitsMessage = numDocs > 1
? `${numDocs} documents are approaching their limits.`
: `${numDocs} document is approaching its limits.`;
return dom.create(Banner, {
content: buildBannerMessage(
limitsMessage,
(this._product && isFreeProduct(this._product)
? [' ', buildUpgradeMessage(true)]
: null
),
testId('text'),
),
style: 'warning',
showCloseButton: true,
onClose: () => this._showApproachingLimitBannerPref?.set(false),
});
});
}
private _buildExceedingLimitsBanner(numDocs: number) {
const limitsMessage = numDocs > 1
? `${numDocs} documents have exceeded their limits.`
: `${numDocs} document has exceeded its limits.`;
return dom.create(Banner, {
content: buildBannerMessage(
limitsMessage,
(this._product && isFreeProduct(this._product)
? [' ', buildUpgradeMessage(true)]
: null
),
testId('text'),
),
contentSmall: buildBannerMessage(
(this._product && isFreeProduct(this._product)
? buildUpgradeMessage(true, 'short')
: limitsMessage
),
),
style: 'error',
showCloseButton: false,
showExpandButton: true,
});
}
}