(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 {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 [

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,
});
}
}

View File

@ -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,

View File

@ -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.

View File

@ -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),
}); });
} }

View File

@ -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']))

View File

@ -283,12 +283,9 @@ 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,
@ -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'));
}); });
} }

View File

@ -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);
}

View File

@ -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;

View File

@ -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();

View File

@ -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);

View File

@ -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

View File

@ -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;

View 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;
}
}