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 {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 [
|
||||
|
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 {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
|
||||
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 {Holder, Observable, subscribe} 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) {
|
||||
const isDenied = (err as any).code === 'ACL_DENY';
|
||||
const isOwner = this.currentDoc.get()?.access === 'owners';
|
||||
const isDocOwner = isOwner(this.currentDoc.get());
|
||||
confirmModal(
|
||||
"Error accessing document",
|
||||
"Reload",
|
||||
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 ` +
|
||||
`inaccessible to others. It also disables formulas. ` +
|
||||
`[${err.message}]` :
|
||||
isDenied ? `Sorry, access to this document has been denied. [${err.message}]` :
|
||||
`Document owners can attempt to recover the document. [${err.message}]`,
|
||||
{ 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);
|
||||
window.location.reload(true);
|
||||
}), 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 {urlState} from 'app/client/models/gristUrlState';
|
||||
import {ownerName} from 'app/client/models/WorkspaceInfo';
|
||||
import {OrgUsageSummary} from 'app/common/DocUsage';
|
||||
import {IHomePage} from 'app/common/gristUrls';
|
||||
import {isLongerThan} from 'app/common/gutil';
|
||||
import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs';
|
||||
@ -75,6 +76,8 @@ export interface HomeModel {
|
||||
// user isn't allowed to create a doc.
|
||||
newDocWorkspace: Observable<Workspace|null|"unsaved">;
|
||||
|
||||
currentOrgUsage: Observable<OrgUsageSummary|null>;
|
||||
|
||||
createWorkspace(name: string): Promise<void>;
|
||||
renameWorkspace(id: number, name: string): 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) &&
|
||||
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);
|
||||
|
||||
constructor(private _app: AppModel, clientScope: ClientScope) {
|
||||
@ -187,6 +192,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
|
||||
clientScope);
|
||||
const importSources = ImportSourceElement.fromArray(pluginManager.pluginsList);
|
||||
this.importSources.set(importSources);
|
||||
|
||||
this._updateCurrentOrgUsage().catch(reportError);
|
||||
}
|
||||
|
||||
// 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});
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {DocUsageBanner} from 'app/client/components/DocUsageBanner';
|
||||
import {SiteUsageBanner} from 'app/client/components/SiteUsageBanner';
|
||||
import {domAsync} from 'app/client/lib/domAsync';
|
||||
import {loadBillingPage} from 'app/client/lib/imports';
|
||||
import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs';
|
||||
@ -102,6 +103,7 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) {
|
||||
},
|
||||
headerMain: createTopBarHome(appModel),
|
||||
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 {confirmModal} from 'app/client/ui2018/modals';
|
||||
import {userOverrideParams} from 'app/common/gristUrls';
|
||||
import {isOwner} from 'app/common/roles';
|
||||
import {Disposable, dom, makeTestId, Observable, observable, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-tools-');
|
||||
|
||||
export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element {
|
||||
const docPageModel = gristDoc.docPageModel;
|
||||
const isOwner = docPageModel.currentDoc.get()?.access === 'owners';
|
||||
const isDocOwner = isOwner(docPageModel.currentDoc.get());
|
||||
const isOverridden = Boolean(docPageModel.userOverride.get());
|
||||
const canViewAccessRules = observable(false);
|
||||
function updateCanViewAccessRules() {
|
||||
canViewAccessRules.set((isOwner && !isOverridden) ||
|
||||
canViewAccessRules.set((isDocOwner && !isOverridden) ||
|
||||
gristDoc.docModel.rules.getNumRows() > 0);
|
||||
}
|
||||
owner.autoDispose(gristDoc.docModel.rules.tableData.tableActionEmitter.addListener(updateCanViewAccessRules));
|
||||
@ -103,7 +104,7 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
|
||||
testId('doctour'),
|
||||
),
|
||||
),
|
||||
!isOwner ? null : cssPageEntrySmall(
|
||||
!isDocOwner ? null : cssPageEntrySmall(
|
||||
cssPageLink(cssPageIcon('Remove'),
|
||||
dom.on('click', () => confirmModal('Delete document tour?', 'Delete', () =>
|
||||
gristDoc.docData.sendAction(['RemoveTable', 'GristDocTour']))
|
||||
|
@ -283,12 +283,9 @@ export class UserManager extends Disposable {
|
||||
!member.name ? null : cssMemberSecondary(
|
||||
member.email, dom.cls('member-email'), testId('um-member-email')
|
||||
),
|
||||
dom('span',
|
||||
(this._model.isPersonal
|
||||
? this._buildSelfAnnotationDom(member)
|
||||
: this._buildAnnotationDom(member)
|
||||
),
|
||||
testId('um-member-annotation'),
|
||||
(this._model.isPersonal
|
||||
? this._buildSelfAnnotationDom(member)
|
||||
: this._buildAnnotationDom(member)
|
||||
),
|
||||
),
|
||||
member.isRemoved ? null : this._memberRoleSelector(member.effectiveAccess,
|
||||
@ -364,15 +361,18 @@ export class UserManager extends Disposable {
|
||||
const annotation = annotations.users.get(user.email);
|
||||
if (!annotation) { return null; }
|
||||
|
||||
let memberType: string;
|
||||
if (annotation.isSupport) {
|
||||
return cssMemberType('Grist support');
|
||||
memberType = 'Grist support';
|
||||
} else if (annotation.isMember && annotations.hasTeam) {
|
||||
return cssMemberType('Team member');
|
||||
memberType = 'Team member';
|
||||
} else if (annotations.hasTeam) {
|
||||
return cssMemberType('Outside collaborator');
|
||||
memberType = 'Outside collaborator';
|
||||
} else {
|
||||
return cssMemberType('Collaborator');
|
||||
memberType = 'Collaborator';
|
||||
}
|
||||
|
||||
return cssMemberType(memberType, testId('um-member-annotation'));
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -3,6 +3,12 @@ export interface SnapshotWindow {
|
||||
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.
|
||||
export interface Features {
|
||||
vanityDomain?: boolean; // are user-selected domains allowed (unenforced) (default: true)
|
||||
@ -60,3 +66,8 @@ export interface Features {
|
||||
export function canAddOrgMembers(features: Features): boolean {
|
||||
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 {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
|
||||
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 {isClient} from 'app/common/gristUrls';
|
||||
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
|
||||
// an incomplete list. For example, node streaming types are supported by node-fetch.
|
||||
export type UploadType = string | Blob | Buffer;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import {isOwner} from 'app/common/roles';
|
||||
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) {
|
||||
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');
|
||||
}
|
||||
const billing = api.getBillingAPI();
|
||||
|
@ -1,3 +1,5 @@
|
||||
import {Organization} from 'app/common/UserAPI';
|
||||
|
||||
export const OWNER = 'owners';
|
||||
export const EDITOR = 'editors';
|
||||
export const VIEWER = 'viewers';
|
||||
@ -39,6 +41,15 @@ export function canView(role: string|null): boolean {
|
||||
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.
|
||||
export function isValidRole(role: string|null): role is Role|null {
|
||||
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 * as assert from 'assert';
|
||||
import {BillingAccount} from 'app/gen-server/entity/BillingAccount';
|
||||
@ -67,14 +67,6 @@ export const suspendedFeatures: Features = {
|
||||
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
|
||||
|
@ -17,10 +17,10 @@ export class HostedMetadataManager {
|
||||
private _lastPushTime: number = 0.0;
|
||||
|
||||
// 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.
|
||||
private _push: Promise<any>|null;
|
||||
private _push: Promise<void>|null;
|
||||
|
||||
// The default delay in milliseconds between metadata pushes to the database.
|
||||
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