mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
2f3cf59fc3
commit
74ec9358da
165
app/client/components/Banner.ts
Normal file
165
app/client/components/Banner.ts
Normal 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;
|
||||||
|
`);
|
@ -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;
|
|
@ -6,9 +6,10 @@ import {icon} from 'app/client/ui2018/icons';
|
|||||||
import {cssLink} from 'app/client/ui2018/links';
|
import {cssLink} from 'app/client/ui2018/links';
|
||||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/DocUsage';
|
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 {commonUrls} from 'app/common/gristUrls';
|
||||||
import {capitalizeFirstWord} from 'app/common/gutil';
|
import {capitalizeFirstWord} from 'app/common/gutil';
|
||||||
|
import {canUpgradeOrg} from 'app/common/roles';
|
||||||
import {Computed, Disposable, dom, DomContents, DomElementArg, makeTestId, styled} from 'grainjs';
|
import {Computed, Disposable, dom, DomContents, DomElementArg, makeTestId, styled} from 'grainjs';
|
||||||
|
|
||||||
const testId = makeTestId('test-doc-usage-');
|
const testId = makeTestId('test-doc-usage-');
|
||||||
@ -156,12 +157,15 @@ export class DocumentUsage extends Disposable {
|
|||||||
const status = use(this._dataLimitStatus);
|
const status = use(this._dataLimitStatus);
|
||||||
if (!org || !status) { return null; }
|
if (!org || !status) { return null; }
|
||||||
|
|
||||||
|
const product = org.billingAccount?.product;
|
||||||
return buildMessage([
|
return buildMessage([
|
||||||
buildLimitStatusMessage(status, org.billingAccount?.product.features, {
|
buildLimitStatusMessage(status, product?.features, {
|
||||||
disableRawDataLink: true
|
disableRawDataLink: true
|
||||||
}),
|
}),
|
||||||
' ',
|
(product && isFreeProduct(product)
|
||||||
buildUpgradeMessage(org.access === 'owners')
|
? [' ', buildUpgradeMessage(canUpgradeOrg(org))]
|
||||||
|
: null
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -226,8 +230,8 @@ export function buildLimitStatusMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildUpgradeMessage(isOwner: boolean, variant: 'short' | 'long' = 'long') {
|
export function buildUpgradeMessage(canUpgrade: boolean, variant: 'short' | 'long' = 'long') {
|
||||||
if (!isOwner) { return 'Contact the site owner to upgrade the plan to raise limits.'; }
|
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.';
|
const upgradeLinkText = 'start your 30-day free trial of the Pro plan.';
|
||||||
return [
|
return [
|
||||||
|
93
app/client/components/SiteUsageBanner.ts
Normal file
93
app/client/components/SiteUsageBanner.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -20,7 +20,7 @@ import {OpenDocMode, UserOverride} from 'app/common/DocListAPI';
|
|||||||
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
|
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
|
||||||
import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
|
import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
|
||||||
import {getReconnectTimeout} from 'app/common/gutil';
|
import {getReconnectTimeout} from 'app/common/gutil';
|
||||||
import {canEdit} from 'app/common/roles';
|
import {canEdit, isOwner} from 'app/common/roles';
|
||||||
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
|
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
|
||||||
import {Holder, Observable, subscribe} from 'grainjs';
|
import {Holder, Observable, subscribe} from 'grainjs';
|
||||||
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
|
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
|
||||||
@ -210,19 +210,19 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
|||||||
|
|
||||||
public offerRecovery(err: Error) {
|
public offerRecovery(err: Error) {
|
||||||
const isDenied = (err as any).code === 'ACL_DENY';
|
const isDenied = (err as any).code === 'ACL_DENY';
|
||||||
const isOwner = this.currentDoc.get()?.access === 'owners';
|
const isDocOwner = isOwner(this.currentDoc.get());
|
||||||
confirmModal(
|
confirmModal(
|
||||||
"Error accessing document",
|
"Error accessing document",
|
||||||
"Reload",
|
"Reload",
|
||||||
async () => window.location.reload(true),
|
async () => window.location.reload(true),
|
||||||
isOwner ? `You can try reloading the document, or using recovery mode. ` +
|
isDocOwner ? `You can try reloading the document, or using recovery mode. ` +
|
||||||
`Recovery mode opens the document to be fully accessible to owners, and ` +
|
`Recovery mode opens the document to be fully accessible to owners, and ` +
|
||||||
`inaccessible to others. It also disables formulas. ` +
|
`inaccessible to others. It also disables formulas. ` +
|
||||||
`[${err.message}]` :
|
`[${err.message}]` :
|
||||||
isDenied ? `Sorry, access to this document has been denied. [${err.message}]` :
|
isDenied ? `Sorry, access to this document has been denied. [${err.message}]` :
|
||||||
`Document owners can attempt to recover the document. [${err.message}]`,
|
`Document owners can attempt to recover the document. [${err.message}]`,
|
||||||
{ hideCancel: true,
|
{ hideCancel: true,
|
||||||
extraButtons: (isOwner && !isDenied) ? bigBasicButton('Enter recovery mode', dom.on('click', async () => {
|
extraButtons: (isDocOwner && !isDenied) ? bigBasicButton('Enter recovery mode', dom.on('click', async () => {
|
||||||
await this._api.getDocAPI(this.currentDocId.get()!).recover(true);
|
await this._api.getDocAPI(this.currentDocId.get()!).recover(true);
|
||||||
window.location.reload(true);
|
window.location.reload(true);
|
||||||
}), testId('modal-recovery-mode')) : null,
|
}), testId('modal-recovery-mode')) : null,
|
||||||
|
@ -7,6 +7,7 @@ import {AppModel, reportError} from 'app/client/models/AppModel';
|
|||||||
import {reportMessage, UserError} from 'app/client/models/errors';
|
import {reportMessage, UserError} from 'app/client/models/errors';
|
||||||
import {urlState} from 'app/client/models/gristUrlState';
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import {ownerName} from 'app/client/models/WorkspaceInfo';
|
import {ownerName} from 'app/client/models/WorkspaceInfo';
|
||||||
|
import {OrgUsageSummary} from 'app/common/DocUsage';
|
||||||
import {IHomePage} from 'app/common/gristUrls';
|
import {IHomePage} from 'app/common/gristUrls';
|
||||||
import {isLongerThan} from 'app/common/gutil';
|
import {isLongerThan} from 'app/common/gutil';
|
||||||
import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs';
|
import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs';
|
||||||
@ -75,6 +76,8 @@ export interface HomeModel {
|
|||||||
// user isn't allowed to create a doc.
|
// user isn't allowed to create a doc.
|
||||||
newDocWorkspace: Observable<Workspace|null|"unsaved">;
|
newDocWorkspace: Observable<Workspace|null|"unsaved">;
|
||||||
|
|
||||||
|
currentOrgUsage: Observable<OrgUsageSummary|null>;
|
||||||
|
|
||||||
createWorkspace(name: string): Promise<void>;
|
createWorkspace(name: string): Promise<void>;
|
||||||
renameWorkspace(id: number, name: string): Promise<void>;
|
renameWorkspace(id: number, name: string): Promise<void>;
|
||||||
deleteWorkspace(id: number, forever: boolean): Promise<void>;
|
deleteWorkspace(id: number, forever: boolean): Promise<void>;
|
||||||
@ -155,6 +158,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
|
|||||||
wss.every((ws) => ws.isSupportWorkspace || ws.docs.length === 0) &&
|
wss.every((ws) => ws.isSupportWorkspace || ws.docs.length === 0) &&
|
||||||
Boolean(use(this.newDocWorkspace))));
|
Boolean(use(this.newDocWorkspace))));
|
||||||
|
|
||||||
|
public readonly currentOrgUsage: Observable<OrgUsageSummary|null> = Observable.create(this, null);
|
||||||
|
|
||||||
private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(this, this._app.currentOrg?.userOrgPrefs);
|
private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(this, this._app.currentOrg?.userOrgPrefs);
|
||||||
|
|
||||||
constructor(private _app: AppModel, clientScope: ClientScope) {
|
constructor(private _app: AppModel, clientScope: ClientScope) {
|
||||||
@ -187,6 +192,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
|
|||||||
clientScope);
|
clientScope);
|
||||||
const importSources = ImportSourceElement.fromArray(pluginManager.pluginsList);
|
const importSources = ImportSourceElement.fromArray(pluginManager.pluginsList);
|
||||||
this.importSources.set(importSources);
|
this.importSources.set(importSources);
|
||||||
|
|
||||||
|
this._updateCurrentOrgUsage().catch(reportError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accessor for the AppModel containing this HomeModel.
|
// Accessor for the AppModel containing this HomeModel.
|
||||||
@ -379,6 +386,14 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
|
|||||||
await this._app.api.updateOrg('current', {userOrgPrefs: org.userOrgPrefs});
|
await this._app.api.updateOrg('current', {userOrgPrefs: org.userOrgPrefs});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _updateCurrentOrgUsage() {
|
||||||
|
const currentOrg = this.app.currentOrg;
|
||||||
|
if (!roles.isOwner(currentOrg)) { return; }
|
||||||
|
|
||||||
|
const api = this.app.api;
|
||||||
|
this.currentOrgUsage.set(await api.getOrgUsageSummary(currentOrg.id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if active product allows just a single workspace.
|
// Check if active product allows just a single workspace.
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {DocUsageBanner} from 'app/client/components/DocUsageBanner';
|
import {DocUsageBanner} from 'app/client/components/DocUsageBanner';
|
||||||
|
import {SiteUsageBanner} from 'app/client/components/SiteUsageBanner';
|
||||||
import {domAsync} from 'app/client/lib/domAsync';
|
import {domAsync} from 'app/client/lib/domAsync';
|
||||||
import {loadBillingPage} from 'app/client/lib/imports';
|
import {loadBillingPage} from 'app/client/lib/imports';
|
||||||
import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs';
|
import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs';
|
||||||
@ -102,6 +103,7 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) {
|
|||||||
},
|
},
|
||||||
headerMain: createTopBarHome(appModel),
|
headerMain: createTopBarHome(appModel),
|
||||||
contentMain: createDocMenu(pageModel),
|
contentMain: createDocMenu(pageModel),
|
||||||
|
contentTop: dom.create(SiteUsageBanner, pageModel),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,17 +13,18 @@ import {cssLink} from 'app/client/ui2018/links';
|
|||||||
import {menuAnnotate} from 'app/client/ui2018/menus';
|
import {menuAnnotate} from 'app/client/ui2018/menus';
|
||||||
import {confirmModal} from 'app/client/ui2018/modals';
|
import {confirmModal} from 'app/client/ui2018/modals';
|
||||||
import {userOverrideParams} from 'app/common/gristUrls';
|
import {userOverrideParams} from 'app/common/gristUrls';
|
||||||
|
import {isOwner} from 'app/common/roles';
|
||||||
import {Disposable, dom, makeTestId, Observable, observable, styled} from 'grainjs';
|
import {Disposable, dom, makeTestId, Observable, observable, styled} from 'grainjs';
|
||||||
|
|
||||||
const testId = makeTestId('test-tools-');
|
const testId = makeTestId('test-tools-');
|
||||||
|
|
||||||
export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element {
|
export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element {
|
||||||
const docPageModel = gristDoc.docPageModel;
|
const docPageModel = gristDoc.docPageModel;
|
||||||
const isOwner = docPageModel.currentDoc.get()?.access === 'owners';
|
const isDocOwner = isOwner(docPageModel.currentDoc.get());
|
||||||
const isOverridden = Boolean(docPageModel.userOverride.get());
|
const isOverridden = Boolean(docPageModel.userOverride.get());
|
||||||
const canViewAccessRules = observable(false);
|
const canViewAccessRules = observable(false);
|
||||||
function updateCanViewAccessRules() {
|
function updateCanViewAccessRules() {
|
||||||
canViewAccessRules.set((isOwner && !isOverridden) ||
|
canViewAccessRules.set((isDocOwner && !isOverridden) ||
|
||||||
gristDoc.docModel.rules.getNumRows() > 0);
|
gristDoc.docModel.rules.getNumRows() > 0);
|
||||||
}
|
}
|
||||||
owner.autoDispose(gristDoc.docModel.rules.tableData.tableActionEmitter.addListener(updateCanViewAccessRules));
|
owner.autoDispose(gristDoc.docModel.rules.tableData.tableActionEmitter.addListener(updateCanViewAccessRules));
|
||||||
@ -103,7 +104,7 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
|
|||||||
testId('doctour'),
|
testId('doctour'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
!isOwner ? null : cssPageEntrySmall(
|
!isDocOwner ? null : cssPageEntrySmall(
|
||||||
cssPageLink(cssPageIcon('Remove'),
|
cssPageLink(cssPageIcon('Remove'),
|
||||||
dom.on('click', () => confirmModal('Delete document tour?', 'Delete', () =>
|
dom.on('click', () => confirmModal('Delete document tour?', 'Delete', () =>
|
||||||
gristDoc.docData.sendAction(['RemoveTable', 'GristDocTour']))
|
gristDoc.docData.sendAction(['RemoveTable', 'GristDocTour']))
|
||||||
|
@ -283,13 +283,10 @@ export class UserManager extends Disposable {
|
|||||||
!member.name ? null : cssMemberSecondary(
|
!member.name ? null : cssMemberSecondary(
|
||||||
member.email, dom.cls('member-email'), testId('um-member-email')
|
member.email, dom.cls('member-email'), testId('um-member-email')
|
||||||
),
|
),
|
||||||
dom('span',
|
|
||||||
(this._model.isPersonal
|
(this._model.isPersonal
|
||||||
? this._buildSelfAnnotationDom(member)
|
? this._buildSelfAnnotationDom(member)
|
||||||
: this._buildAnnotationDom(member)
|
: this._buildAnnotationDom(member)
|
||||||
),
|
),
|
||||||
testId('um-member-annotation'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
member.isRemoved ? null : this._memberRoleSelector(member.effectiveAccess,
|
member.isRemoved ? null : this._memberRoleSelector(member.effectiveAccess,
|
||||||
member.inheritedAccess, this._model.isActiveUser(member)),
|
member.inheritedAccess, this._model.isActiveUser(member)),
|
||||||
@ -364,15 +361,18 @@ export class UserManager extends Disposable {
|
|||||||
const annotation = annotations.users.get(user.email);
|
const annotation = annotations.users.get(user.email);
|
||||||
if (!annotation) { return null; }
|
if (!annotation) { return null; }
|
||||||
|
|
||||||
|
let memberType: string;
|
||||||
if (annotation.isSupport) {
|
if (annotation.isSupport) {
|
||||||
return cssMemberType('Grist support');
|
memberType = 'Grist support';
|
||||||
} else if (annotation.isMember && annotations.hasTeam) {
|
} else if (annotation.isMember && annotations.hasTeam) {
|
||||||
return cssMemberType('Team member');
|
memberType = 'Team member';
|
||||||
} else if (annotations.hasTeam) {
|
} else if (annotations.hasTeam) {
|
||||||
return cssMemberType('Outside collaborator');
|
memberType = 'Outside collaborator';
|
||||||
} else {
|
} else {
|
||||||
return cssMemberType('Collaborator');
|
memberType = 'Collaborator';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return cssMemberType(memberType, testId('um-member-annotation'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,6 +3,12 @@ export interface SnapshotWindow {
|
|||||||
unit: 'days' | 'month' | 'year';
|
unit: 'days' | 'month' | 'year';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Information about the product associated with an org or orgs.
|
||||||
|
export interface Product {
|
||||||
|
name: string;
|
||||||
|
features: Features;
|
||||||
|
}
|
||||||
|
|
||||||
// A product is essentially a list of flags and limits that we may enforce/support.
|
// A product is essentially a list of flags and limits that we may enforce/support.
|
||||||
export interface Features {
|
export interface Features {
|
||||||
vanityDomain?: boolean; // are user-selected domains allowed (unenforced) (default: true)
|
vanityDomain?: boolean; // are user-selected domains allowed (unenforced) (default: true)
|
||||||
@ -60,3 +66,8 @@ export interface Features {
|
|||||||
export function canAddOrgMembers(features: Features): boolean {
|
export function canAddOrgMembers(features: Features): boolean {
|
||||||
return features.maxWorkspacesPerOrg !== 1;
|
return features.maxWorkspacesPerOrg !== 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns true if `product` is free.
|
||||||
|
export function isFreeProduct(product: Product): boolean {
|
||||||
|
return ['starter', 'teamFree'].includes(product.name);
|
||||||
|
}
|
||||||
|
@ -6,7 +6,7 @@ import {BrowserSettings} from 'app/common/BrowserSettings';
|
|||||||
import {BulkColValues, TableColValues, TableRecordValue, TableRecordValues, UserAction} from 'app/common/DocActions';
|
import {BulkColValues, TableColValues, TableRecordValue, TableRecordValues, UserAction} from 'app/common/DocActions';
|
||||||
import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
|
import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
|
||||||
import {OrgUsageSummary} from 'app/common/DocUsage';
|
import {OrgUsageSummary} from 'app/common/DocUsage';
|
||||||
import {Features} from 'app/common/Features';
|
import {Product} from 'app/common/Features';
|
||||||
import {ICustomWidget} from 'app/common/CustomWidget';
|
import {ICustomWidget} from 'app/common/CustomWidget';
|
||||||
import {isClient} from 'app/common/gristUrls';
|
import {isClient} from 'app/common/gristUrls';
|
||||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||||
@ -73,12 +73,6 @@ export interface BillingAccount {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Information about the product associated with an org or orgs.
|
|
||||||
export interface Product {
|
|
||||||
name: string;
|
|
||||||
features: Features;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The upload types vary based on which fetch implementation is in use. This is
|
// The upload types vary based on which fetch implementation is in use. This is
|
||||||
// an incomplete list. For example, node streaming types are supported by node-fetch.
|
// an incomplete list. For example, node streaming types are supported by node-fetch.
|
||||||
export type UploadType = string | Blob | Buffer;
|
export type UploadType = string | Blob | Buffer;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import {isOwner} from 'app/common/roles';
|
||||||
import {ManagerDelta, PermissionDelta, UserAPI} from 'app/common/UserAPI';
|
import {ManagerDelta, PermissionDelta, UserAPI} from 'app/common/UserAPI';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -7,7 +8,7 @@ import {ManagerDelta, PermissionDelta, UserAPI} from 'app/common/UserAPI';
|
|||||||
*/
|
*/
|
||||||
export async function resetOrg(api: UserAPI, org: string|number) {
|
export async function resetOrg(api: UserAPI, org: string|number) {
|
||||||
const session = await api.getSessionActive();
|
const session = await api.getSessionActive();
|
||||||
if (!(session.org && session.org.access === 'owners')) {
|
if (!isOwner(session.org)) {
|
||||||
throw new Error('user must be an owner of the org to be reset');
|
throw new Error('user must be an owner of the org to be reset');
|
||||||
}
|
}
|
||||||
const billing = api.getBillingAPI();
|
const billing = api.getBillingAPI();
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import {Organization} from 'app/common/UserAPI';
|
||||||
|
|
||||||
export const OWNER = 'owners';
|
export const OWNER = 'owners';
|
||||||
export const EDITOR = 'editors';
|
export const EDITOR = 'editors';
|
||||||
export const VIEWER = 'viewers';
|
export const VIEWER = 'viewers';
|
||||||
@ -39,6 +41,15 @@ export function canView(role: string|null): boolean {
|
|||||||
return role !== null;
|
return role !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isOwner(resource: {access: Role}|null): resource is {access: Role} {
|
||||||
|
return resource?.access === OWNER;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUpgradeOrg(org: Organization|null): org is Organization {
|
||||||
|
// TODO: Need to consider billing managers and support user.
|
||||||
|
return isOwner(org);
|
||||||
|
}
|
||||||
|
|
||||||
// Returns true if the role string is a valid role or null.
|
// Returns true if the role string is a valid role or null.
|
||||||
export function isValidRole(role: string|null): role is Role|null {
|
export function isValidRole(role: string|null): role is Role|null {
|
||||||
return (roleOrder as Array<string|null>).includes(role);
|
return (roleOrder as Array<string|null>).includes(role);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import {Features} from 'app/common/Features';
|
import {Features, Product as IProduct} from 'app/common/Features';
|
||||||
import {nativeValues} from 'app/gen-server/lib/values';
|
import {nativeValues} from 'app/gen-server/lib/values';
|
||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import {BillingAccount} from 'app/gen-server/entity/BillingAccount';
|
import {BillingAccount} from 'app/gen-server/entity/BillingAccount';
|
||||||
@ -67,14 +67,6 @@ export const suspendedFeatures: Features = {
|
|||||||
maxWorkspacesPerOrg: 0,
|
maxWorkspacesPerOrg: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Basic fields needed for products supported by Grist.
|
|
||||||
*/
|
|
||||||
export interface IProduct {
|
|
||||||
name: string;
|
|
||||||
features: Features;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Products are a bundle of enabled features. Most products in
|
* Products are a bundle of enabled features. Most products in
|
||||||
|
@ -17,10 +17,10 @@ export class HostedMetadataManager {
|
|||||||
private _lastPushTime: number = 0.0;
|
private _lastPushTime: number = 0.0;
|
||||||
|
|
||||||
// Callback for next opportunity to push changes.
|
// Callback for next opportunity to push changes.
|
||||||
private _timeout: any = null;
|
private _timeout: NodeJS.Timeout|null = null;
|
||||||
|
|
||||||
// Maintains the update Promise to wait on it if the class is closing.
|
// Maintains the update Promise to wait on it if the class is closing.
|
||||||
private _push: Promise<any>|null;
|
private _push: Promise<void>|null;
|
||||||
|
|
||||||
// The default delay in milliseconds between metadata pushes to the database.
|
// The default delay in milliseconds between metadata pushes to the database.
|
||||||
private readonly _minPushDelayMs: number;
|
private readonly _minPushDelayMs: number;
|
||||||
|
10
stubs/app/client/component/DocUsageBanner.ts
Normal file
10
stubs/app/client/component/DocUsageBanner.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||||
|
import {Disposable} from 'grainjs';
|
||||||
|
|
||||||
|
export class DocUsageBanner extends Disposable {
|
||||||
|
constructor(_docPageModel: DocPageModel) { super(); }
|
||||||
|
|
||||||
|
public buildDom() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user