(core) Add document usage banners

Summary:
This also enables the new Usage section for all sites. Currently,
it shows metrics for document row count, but only if the user
has full document read access. Otherwise, a message about
insufficient access is shown.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: alexmojaki

Differential Revision: https://phab.getgrist.com/D3377
This commit is contained in:
George Gevoian 2022-04-21 10:57:33 -07:00
parent 01b1c310b5
commit af5b3c9004
14 changed files with 543 additions and 205 deletions

View File

@ -0,0 +1,171 @@
import {buildUpgradeMessage, getLimitStatusMessage} 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 _currentDoc = this._docPageModel.currentDoc;
private readonly _currentDocId = this._docPageModel.currentDocId;
private readonly _dataLimitStatus = this._docPageModel.dataLimitStatus;
private readonly _currentOrg = Computed.create(this, this._currentDoc, (_use, doc) => {
return doc?.workspace.org ?? 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(
getLimitStatusMessage('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 [
getLimitStatusMessage(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

@ -3,87 +3,196 @@ import {docListHeader} from 'app/client/ui/DocMenuCss';
import {colors, mediaXSmall} from 'app/client/ui2018/cssVars'; import {colors, mediaXSmall} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links'; import {cssLink} from 'app/client/ui2018/links';
import {DataLimitStatus} from 'app/common/ActiveDocAPI'; import {loadingSpinner} from 'app/client/ui2018/loaders';
import {Features} from 'app/common/Features';
import {commonUrls} from 'app/common/gristUrls'; import {commonUrls} from 'app/common/gristUrls';
import {Computed, Disposable, dom, IDisposableOwner, Observable, styled} from 'grainjs'; import {capitalizeFirstWord} from 'app/common/gutil';
import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/Usage';
import {Computed, Disposable, dom, DomContents, DomElementArg, makeTestId, styled} from 'grainjs';
const limitStatusMessages: Record<NonNullable<DataLimitStatus>, string> = { const testId = makeTestId('test-doc-usage-');
approachingLimit: 'This document is approaching free plan limits.',
deleteOnly: 'This document is now in delete-only mode.', // Default used by the progress bar to visually indicate row usage.
gracePeriod: 'This document has exceeded free plan limits.', const DEFAULT_MAX_ROWS = 20000;
};
const ACCESS_DENIED_MESSAGE = 'Usage statistics are only available to users with '
+ 'full access to the document data.';
/** /**
* Displays statistics about document usage, such as number of rows used. * Displays statistics about document usage, such as number of rows used.
*
* Currently only shows usage if current site is a free team site.
*/ */
export class DocumentUsage extends Disposable { export class DocumentUsage extends Disposable {
private readonly _currentDoc = this._docPageModel.currentDoc;
private readonly _dataLimitStatus = this._docPageModel.dataLimitStatus;
private readonly _rowCount = this._docPageModel.rowCount;
private readonly _currentOrg = Computed.create(this, this._currentDoc, (_use, doc) => {
return doc?.workspace.org ?? null;
});
private readonly _rowMetrics: Computed<MetricOptions | null> =
Computed.create(this, this._currentOrg, this._rowCount, (_use, org, rowCount) => {
const features = org?.billingAccount?.product.features;
if (!features || typeof rowCount !== 'number') { return null; }
const {baseMaxRowsPerDocument: maxRows} = features;
// Invalid row limits are currently treated as if they are undefined.
const maxValue = maxRows && maxRows > 0 ? maxRows : undefined;
return {
name: 'Rows',
currentValue: rowCount,
maximumValue: maxValue ?? DEFAULT_MAX_ROWS,
unit: 'rows',
shouldHideLimits: maxValue === undefined,
};
});
private readonly _isLoading: Computed<boolean> =
Computed.create(this, this._currentDoc, this._rowCount, (_use, doc, rowCount) => {
return doc === null || rowCount === 'pending';
});
private readonly _isAccessDenied: Computed<boolean | null> =
Computed.create(
this, this._isLoading, this._currentDoc, this._rowCount,
(_use, isLoading, doc, rowCount) => {
if (isLoading) { return null; }
const {access} = doc!.workspace.org;
const isPublicUser = access === 'guests' || access === null;
return isPublicUser || rowCount === 'hidden';
}
);
constructor(private _docPageModel: DocPageModel) { constructor(private _docPageModel: DocPageModel) {
super(); super();
} }
public buildDom() { public buildDom() {
const features = this._docPageModel.appModel.currentFeatures;
if (features.baseMaxRowsPerDocument === undefined) { return null; }
return dom('div', return dom('div',
cssHeader('Usage'), cssHeader('Usage', testId('heading')),
dom.domComputed(this._docPageModel.dataLimitStatus, status => { dom.domComputed(this._isLoading, (isLoading) => {
if (!status) { return null; } if (isLoading) { return cssSpinner(loadingSpinner(), testId('loading')); }
return cssLimitWarning( return [this._buildMessage(), this._buildMetrics()];
cssIcon('Idea'),
cssLightlyBoldedText(
limitStatusMessages[status],
' For higher limits, ',
cssUnderlinedLink('start your 30-day free trial of the Pro plan.', {
href: commonUrls.plans,
target: '_blank',
}),
),
);
}), }),
testId('container'),
);
}
private _buildMessage() {
return dom.domComputed((use) => {
const isAccessDenied = use(this._isAccessDenied);
if (isAccessDenied === null) { return null; }
if (isAccessDenied) { return buildMessage(ACCESS_DENIED_MESSAGE); }
const org = use(this._currentOrg);
const status = use(this._dataLimitStatus);
if (!org || !status) { return null; }
return buildMessage([
getLimitStatusMessage(status, org.billingAccount?.product.features),
' ',
buildUpgradeMessage(org.access === 'owners')
]);
});
}
private _buildMetrics() {
return dom.maybe(use => use(this._isAccessDenied) === false, () =>
cssUsageMetrics( cssUsageMetrics(
dom.create(buildUsageMetric, { dom.maybe(this._rowMetrics, (metrics) =>
name: 'Rows', buildUsageMetric(metrics, testId('rows')),
currentValue: this._docPageModel.rowCount, ),
maximumValue: features.baseMaxRowsPerDocument, testId('metrics'),
units: 'rows', ),
}),
)
); );
} }
} }
function buildMessage(message: DomContents) {
return cssWarningMessage(
cssIcon('Idea'),
cssLightlyBoldedText(message, testId('message-text')),
testId('message'),
);
}
interface MetricOptions {
name: string;
currentValue: number;
// If undefined or non-positive (i.e. invalid), no limits will be assumed.
maximumValue?: number;
unit?: string;
// If true, limits will always be hidden, even if `maximumValue` is a positive number.
shouldHideLimits?: boolean;
}
/** /**
* Builds a component which displays the current and maximum values for * Builds a component which displays the current and maximum values for
* a particular metric (e.g. rows), and a progress meter showing how * a particular metric (e.g. row count), and a progress meter showing how
* close `currentValue` is to hitting `maximumValue`. * close `currentValue` is to hitting `maximumValue`.
*/ */
function buildUsageMetric(owner: IDisposableOwner, {name, currentValue, maximumValue, units}: { function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) {
name: string; const {name, currentValue, maximumValue, unit, shouldHideLimits} = options;
currentValue: Observable<number | undefined>; const ratioUsed = currentValue / (maximumValue || Infinity);
maximumValue: number; const percentUsed = Math.min(100, Math.floor(ratioUsed * 100));
units?: string;
}) {
const percentUsed = Computed.create(owner, currentValue, (_use, value) => {
return Math.min(100, Math.floor(((value ?? 0) / maximumValue) * 100));
});
return cssUsageMetric( return cssUsageMetric(
cssMetricName(name), cssMetricName(name, testId('name')),
cssProgressBarContainer( cssProgressBarContainer(
cssProgressBarFill( cssProgressBarFill(
dom.style('width', use => `${use(percentUsed)}%`), {style: `width: ${percentUsed}%`},
cssProgressBarFill.cls('-approaching-limit', use => use(percentUsed) >= 90) // Change progress bar to red if close to limit, unless limits are hidden.
) shouldHideLimits || ratioUsed <= APPROACHING_LIMIT_RATIO
? null
: cssProgressBarFill.cls('-approaching-limit'),
testId('progress-fill'),
),
), ),
dom.maybe(currentValue, value => dom('div',
dom('div', `${value} of ${maximumValue}` + (units ? ` ${units}` : '')) currentValue
+ (shouldHideLimits || !maximumValue ? '' : ' of ' + maximumValue)
+ (unit ? ` ${unit}` : ''),
testId('value'),
), ),
...domArgs,
); );
} }
export function getLimitStatusMessage(status: NonNullable<DataLimitStatus>, features?: Features): string {
switch (status) {
case 'approachingLimit': {
return 'This document is approaching free plan limits.';
}
case 'gracePeriod': {
const gracePeriodDays = features?.gracePeriodDays;
if (!gracePeriodDays) { return 'Document limits exceeded.'; }
return `Document limits exceeded. In ${gracePeriodDays} days, this document will be read-only.`;
}
case 'deleteOnly': {
return 'This document exceeded free plan limits and is now read-only, but you can delete rows.';
}
}
}
export function buildUpgradeMessage(isOwner: boolean, variant: 'short' | 'long' = 'long') {
if (!isOwner) { 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 [
variant === 'short' ? null : 'For higher limits, ',
buildUpgradeLink(variant === 'short' ? capitalizeFirstWord(upgradeLinkText) : upgradeLinkText),
];
}
export function buildUpgradeLink(linkText: string) {
return cssUnderlinedLink(linkText, {
href: commonUrls.plans,
target: '_blank',
});
}
const cssLightlyBoldedText = styled('div', ` const cssLightlyBoldedText = styled('div', `
font-weight: 500; font-weight: 500;
`); `);
@ -93,7 +202,7 @@ const cssIconAndText = styled('div', `
gap: 16px; gap: 16px;
`); `);
const cssLimitWarning = styled(cssIconAndText, ` const cssWarningMessage = styled(cssIconAndText, `
margin-top: 16px; margin-top: 16px;
`); `);
@ -112,7 +221,6 @@ const cssHeader = styled(docListHeader, `
`); `);
const cssUnderlinedLink = styled(cssLink, ` const cssUnderlinedLink = styled(cssLink, `
display: inline-block;
color: unset; color: unset;
text-decoration: underline; text-decoration: underline;
@ -161,3 +269,9 @@ const cssProgressBarFill = styled(cssProgressBarContainer, `
background: ${colors.error}; background: ${colors.error};
} }
`); `);
const cssSpinner = styled('div', `
display: flex;
justify-content: center;
margin-top: 32px;
`);

View File

@ -24,17 +24,34 @@ export function getStorage(): Storage {
return _storage || (_storage = createStorage()); return _storage || (_storage = createStorage());
} }
/**
* Similar to `getStorage`, but always returns sessionStorage (or an in-memory equivalent).
*/
export function getSessionStorage(): Storage {
return _sessionStorage || (_sessionStorage = createSessionStorage());
}
let _storage: Storage|undefined; let _storage: Storage|undefined;
let _sessionStorage: Storage|undefined;
function createStorage(): Storage { function createStorage(): Storage {
if (typeof localStorage !== 'undefined' && testStorage(localStorage)) { if (typeof localStorage !== 'undefined' && testStorage(localStorage)) {
return localStorage; return localStorage;
} else {
return createSessionStorage();
} }
}
function createSessionStorage(): Storage {
if (typeof sessionStorage !== 'undefined' && testStorage(sessionStorage)) { if (typeof sessionStorage !== 'undefined' && testStorage(sessionStorage)) {
return sessionStorage; return sessionStorage;
} else {
// Fall back to a Map-based implementation of (non-persistent) sessionStorage.
return createInMemoryStorage();
} }
}
// Fall back to a Map-based implementation of (non-persistent) localStorage. function createInMemoryStorage(): Storage {
const values = new Map<string, string>(); const values = new Map<string, string>();
return { return {
setItem(key: string, val: string) { values.set(key, val); }, setItem(key: string, val: string) { values.set(key, val); },
@ -46,6 +63,13 @@ function createStorage(): Storage {
}; };
} }
function getStorageBoolObs(store: Storage, key: string, defValue: boolean) {
const storedNegation = defValue ? 'false' : 'true';
const obs = Observable.create(null, store.getItem(key) === storedNegation ? !defValue : defValue);
obs.addListener((val) => val === defValue ? store.removeItem(key) : store.setItem(key, storedNegation));
return obs;
}
/** /**
* Helper to create a boolean observable whose state is stored in localStorage. * Helper to create a boolean observable whose state is stored in localStorage.
* *
@ -53,11 +77,14 @@ function createStorage(): Storage {
* same default value should be used for an observable every time it's created. * same default value should be used for an observable every time it's created.
*/ */
export function localStorageBoolObs(key: string, defValue = false): Observable<boolean> { export function localStorageBoolObs(key: string, defValue = false): Observable<boolean> {
const store = getStorage(); return getStorageBoolObs(getStorage(), key, defValue);
const storedNegation = defValue ? 'false' : 'true'; }
const obs = Observable.create(null, store.getItem(key) === storedNegation ? !defValue : defValue);
obs.addListener((val) => val === defValue ? store.removeItem(key) : store.setItem(key, storedNegation)); /**
return obs; * Similar to `localStorageBoolObs`, but always uses sessionStorage (or an in-memory equivalent).
*/
export function sessionStorageBoolObs(key: string, defValue = false): Observable<boolean> {
return getStorageBoolObs(getSessionStorage(), key, defValue);
} }
/** /**

View File

@ -14,13 +14,13 @@ import {bigBasicButton} from 'app/client/ui2018/buttons';
import {testId} from 'app/client/ui2018/cssVars'; import {testId} from 'app/client/ui2018/cssVars';
import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018/menus'; import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals'; import {confirmModal} from 'app/client/ui2018/modals';
import {DataLimitStatus} from 'app/common/ActiveDocAPI';
import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow'; import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
import {delay} from 'app/common/delay'; import {delay} from 'app/common/delay';
import {OpenDocMode, UserOverride} from 'app/common/DocListAPI'; import {OpenDocMode, UserOverride} from 'app/common/DocListAPI';
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} from 'app/common/roles';
import {DataLimitStatus, RowCount} from 'app/common/Usage';
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';
@ -66,7 +66,7 @@ export interface DocPageModel {
gristDoc: Observable<GristDoc|null>; // Instance of GristDoc once it exists. gristDoc: Observable<GristDoc|null>; // Instance of GristDoc once it exists.
rowCount: Observable<number|undefined>; rowCount: Observable<RowCount>;
dataLimitStatus: Observable<DataLimitStatus|undefined>; dataLimitStatus: Observable<DataLimitStatus|undefined>;
createLeftPane(leftPanelOpen: Observable<boolean>): DomArg; createLeftPane(leftPanelOpen: Observable<boolean>): DomArg;
@ -109,7 +109,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
// Observable set to the instance of GristDoc once it's created. // Observable set to the instance of GristDoc once it's created.
public readonly gristDoc = Observable.create<GristDoc|null>(this, null); public readonly gristDoc = Observable.create<GristDoc|null>(this, null);
public readonly rowCount = Observable.create<number|undefined>(this, undefined); public readonly rowCount = Observable.create<RowCount>(this, 'pending');
public readonly dataLimitStatus = Observable.create<DataLimitStatus|undefined>(this, null); public readonly dataLimitStatus = Observable.create<DataLimitStatus|undefined>(this, null);
// Combination of arguments needed to open a doc (docOrUrlId + openMod). It's obtained from the // Combination of arguments needed to open a doc (docOrUrlId + openMod). It's obtained from the

View File

@ -1,3 +1,4 @@
import {DocUsageBanner} from 'app/client/components/DocUsageBanner';
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';
@ -149,6 +150,7 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App)
contentMain: dom.maybe(pageModel.gristDoc, (gristDoc) => gristDoc.buildDom()), contentMain: dom.maybe(pageModel.gristDoc, (gristDoc) => gristDoc.buildDom()),
onResize, onResize,
testId, testId,
contentBottom: dom.create(createBottomBarDoc, pageModel, leftPanelOpen, rightPanelOpen) contentTop: dom.create(DocUsageBanner, pageModel),
contentBottom: dom.create(createBottomBarDoc, pageModel, leftPanelOpen, rightPanelOpen),
}); });
} }

View File

@ -28,6 +28,7 @@ export interface PageContents {
onResize?: () => void; // Callback for when either pane is opened, closed, or resized. onResize?: () => void; // Callback for when either pane is opened, closed, or resized.
testId?: TestId; testId?: TestId;
contentTop?: DomArg;
contentBottom?: DomArg; contentBottom?: DomArg;
} }
@ -62,119 +63,122 @@ export function pagePanels(page: PageContents) {
return cssPageContainer( return cssPageContainer(
dom.autoDispose(sub1), dom.autoDispose(sub1),
dom.autoDispose(sub2), dom.autoDispose(sub2),
cssLeftPane( page.contentTop,
testId('left-panel'), cssContentMain(
cssTopHeader(left.header), cssLeftPane(
left.content, testId('left-panel'),
cssTopHeader(left.header),
left.content,
dom.style('width', (use) => use(left.panelOpen) ? use(left.panelWidth) + 'px' : ''), dom.style('width', (use) => use(left.panelOpen) ? use(left.panelWidth) + 'px' : ''),
// Opening/closing the left pane, with transitions. // Opening/closing the left pane, with transitions.
cssLeftPane.cls('-open', left.panelOpen), cssLeftPane.cls('-open', left.panelOpen),
transition(use => (use(isNarrowScreenObs()) ? false : use(left.panelOpen)), { transition(use => (use(isNarrowScreenObs()) ? false : use(left.panelOpen)), {
prepare(elem, open) { elem.style.marginRight = (open ? -1 : 1) * (left.panelWidth.get() - 48) + 'px'; }, prepare(elem, open) { elem.style.marginRight = (open ? -1 : 1) * (left.panelWidth.get() - 48) + 'px'; },
run(elem, open) { elem.style.marginRight = ''; }, run(elem, open) { elem.style.marginRight = ''; },
finish: onResize,
}),
),
// Resizer for the left pane.
// TODO: resizing to small size should collapse. possibly should allow expanding too
cssResizeFlexVHandle(
{target: 'left', onSave: (val) => { left.panelWidth.set(val); onResize(); }},
testId('left-resizer'),
dom.show(left.panelOpen),
cssHideForNarrowScreen.cls('')),
// Show plain border when the resize handle is hidden.
cssResizeDisabledBorder(
dom.hide(left.panelOpen),
cssHideForNarrowScreen.cls('')),
cssMainPane(
cssTopHeader(
testId('top-header'),
(left.hideOpener ? null :
cssPanelOpener('PanelRight', cssPanelOpener.cls('-open', left.panelOpen),
testId('left-opener'),
dom.on('click', () => toggleObs(left.panelOpen)),
cssHideForNarrowScreen.cls(''))
),
page.headerMain,
(!right || right.hideOpener ? null :
cssPanelOpener('PanelLeft', cssPanelOpener.cls('-open', right.panelOpen),
testId('right-opener'),
dom.cls('tour-creator-panel'),
dom.on('click', () => toggleObs(right.panelOpen)),
cssHideForNarrowScreen.cls(''))
),
),
page.contentMain,
testId('main-pane'),
),
(right ? [
// Resizer for the right pane.
cssResizeFlexVHandle(
{target: 'right', onSave: (val) => { right.panelWidth.set(val); onResize(); }},
testId('right-resizer'),
dom.show(right.panelOpen),
cssHideForNarrowScreen.cls('')),
cssRightPane(
testId('right-panel'),
cssTopHeader(right.header),
right.content,
dom.style('width', (use) => use(right.panelOpen) ? use(right.panelWidth) + 'px' : ''),
// Opening/closing the right pane, with transitions.
cssRightPane.cls('-open', right.panelOpen),
transition(use => (use(isNarrowScreenObs()) ? false : use(right.panelOpen)), {
prepare(elem, open) { elem.style.marginLeft = (open ? -1 : 1) * right.panelWidth.get() + 'px'; },
run(elem, open) { elem.style.marginLeft = ''; },
finish: onResize, finish: onResize,
}), }),
)] : null ),
),
cssContentOverlay( // Resizer for the left pane.
dom.show((use) => use(left.panelOpen) || Boolean(right && use(right.panelOpen))), // TODO: resizing to small size should collapse. possibly should allow expanding too
dom.on('click', () => { cssResizeFlexVHandle(
left.panelOpen.set(false); {target: 'left', onSave: (val) => { left.panelWidth.set(val); onResize(); }},
if (right) { right.panelOpen.set(false); } testId('left-resizer'),
}), dom.show(left.panelOpen),
testId('overlay') cssHideForNarrowScreen.cls('')),
),
dom.maybe(isNarrowScreenObs(), () => // Show plain border when the resize handle is hidden.
cssBottomFooter( cssResizeDisabledBorder(
testId('bottom-footer'), dom.hide(left.panelOpen),
cssPanelOpenerNarrowScreenBtn( cssHideForNarrowScreen.cls('')),
cssPanelOpenerNarrowScreen(
'FieldTextbox', cssMainPane(
dom.on('click', () => { cssTopHeader(
right?.panelOpen.set(false); testId('top-header'),
toggleObs(left.panelOpen); (left.hideOpener ? null :
}), cssPanelOpener('PanelRight', cssPanelOpener.cls('-open', left.panelOpen),
testId('left-opener-ns') testId('left-opener'),
dom.on('click', () => toggleObs(left.panelOpen)),
cssHideForNarrowScreen.cls(''))
),
page.headerMain,
(!right || right.hideOpener ? null :
cssPanelOpener('PanelLeft', cssPanelOpener.cls('-open', right.panelOpen),
testId('right-opener'),
dom.cls('tour-creator-panel'),
dom.on('click', () => toggleObs(right.panelOpen)),
cssHideForNarrowScreen.cls(''))
), ),
cssPanelOpenerNarrowScreenBtn.cls('-open', left.panelOpen)
), ),
page.contentBottom, page.contentMain,
(!right ? null : testId('main-pane'),
),
(right ? [
// Resizer for the right pane.
cssResizeFlexVHandle(
{target: 'right', onSave: (val) => { right.panelWidth.set(val); onResize(); }},
testId('right-resizer'),
dom.show(right.panelOpen),
cssHideForNarrowScreen.cls('')),
cssRightPane(
testId('right-panel'),
cssTopHeader(right.header),
right.content,
dom.style('width', (use) => use(right.panelOpen) ? use(right.panelWidth) + 'px' : ''),
// Opening/closing the right pane, with transitions.
cssRightPane.cls('-open', right.panelOpen),
transition(use => (use(isNarrowScreenObs()) ? false : use(right.panelOpen)), {
prepare(elem, open) { elem.style.marginLeft = (open ? -1 : 1) * right.panelWidth.get() + 'px'; },
run(elem, open) { elem.style.marginLeft = ''; },
finish: onResize,
}),
)] : null
),
cssContentOverlay(
dom.show((use) => use(left.panelOpen) || Boolean(right && use(right.panelOpen))),
dom.on('click', () => {
left.panelOpen.set(false);
if (right) { right.panelOpen.set(false); }
}),
testId('overlay')
),
dom.maybe(isNarrowScreenObs(), () =>
cssBottomFooter(
testId('bottom-footer'),
cssPanelOpenerNarrowScreenBtn( cssPanelOpenerNarrowScreenBtn(
cssPanelOpenerNarrowScreen( cssPanelOpenerNarrowScreen(
'Settings', 'FieldTextbox',
dom.on('click', () => { dom.on('click', () => {
left.panelOpen.set(false); right?.panelOpen.set(false);
toggleObs(right.panelOpen); toggleObs(left.panelOpen);
}), }),
testId('right-opener-ns') testId('left-opener-ns')
), ),
cssPanelOpenerNarrowScreenBtn.cls('-open', right.panelOpen), cssPanelOpenerNarrowScreenBtn.cls('-open', left.panelOpen)
) ),
), page.contentBottom,
) (!right ? null :
cssPanelOpenerNarrowScreenBtn(
cssPanelOpenerNarrowScreen(
'Settings',
dom.on('click', () => {
left.panelOpen.set(false);
toggleObs(right.panelOpen);
}),
testId('right-opener-ns')
),
cssPanelOpenerNarrowScreenBtn.cls('-open', right.panelOpen),
)
),
)
),
), ),
); );
} }
@ -190,11 +194,10 @@ const cssVBox = styled('div', `
const cssHBox = styled('div', ` const cssHBox = styled('div', `
display: flex; display: flex;
`); `);
const cssPageContainer = styled(cssHBox, ` const cssPageContainer = styled(cssVBox, `
position: absolute; position: absolute;
isolation: isolate; /* Create a new stacking context */ isolation: isolate; /* Create a new stacking context */
z-index: 0; /* As of March 2019, isolation does not have Edge support, so force one with z-index */ z-index: 0; /* As of March 2019, isolation does not have Edge support, so force one with z-index */
overflow: hidden;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
@ -212,7 +215,10 @@ const cssPageContainer = styled(cssHBox, `
} }
} }
`); `);
const cssContentMain = styled(cssHBox, `
flex: 1 1 0px;
overflow: hidden;
`);
export const cssLeftPane = styled(cssVBox, ` export const cssLeftPane = styled(cssVBox, `
position: relative; position: relative;
background-color: ${colors.lightGrey}; background-color: ${colors.lightGrey};

View File

@ -365,6 +365,7 @@ export const cssModalTitle = styled('div', `
color: ${colors.dark}; color: ${colors.dark};
margin: 0 0 16px 0; margin: 0 0 16px 0;
line-height: 32px; line-height: 32px;
overflow-wrap: break-word;
`); `);
export const cssModalBody = styled('div', ` export const cssModalBody = styled('div', `

View File

@ -153,8 +153,6 @@ export interface PermissionDataWithExtraUsers extends PermissionData {
exampleUsers: UserAccessData[]; exampleUsers: UserAccessData[];
} }
export type DataLimitStatus = null | 'approachingLimit' | 'gracePeriod' | 'deleteOnly';
export interface ActiveDocAPI { export interface ActiveDocAPI {
/** /**
* Closes a document, and unsubscribes from its userAction events. * Closes a document, and unsubscribes from its userAction events.

View File

@ -1,8 +1,8 @@
import {MinimalActionGroup} from 'app/common/ActionGroup'; import {MinimalActionGroup} from 'app/common/ActionGroup';
import {DataLimitStatus} from 'app/common/ActiveDocAPI';
import {TableDataAction} from 'app/common/DocActions'; import {TableDataAction} from 'app/common/DocActions';
import {Role} from 'app/common/roles'; import {Role} from 'app/common/roles';
import {StringUnion} from 'app/common/StringUnion'; import {StringUnion} from 'app/common/StringUnion';
import {DataLimitStatus, RowCount} from 'app/common/Usage';
import {FullUser} from 'app/common/UserAPI'; import {FullUser} from 'app/common/UserAPI';
// Possible flavors of items in a list of documents. // Possible flavors of items in a list of documents.
@ -43,9 +43,9 @@ export interface OpenLocalDocResult {
clientId: string; // the docFD is meaningful only in the context of this session clientId: string; // the docFD is meaningful only in the context of this session
doc: {[tableId: string]: TableDataAction}; doc: {[tableId: string]: TableDataAction};
log: MinimalActionGroup[]; log: MinimalActionGroup[];
rowCount: RowCount;
recoveryMode?: boolean; recoveryMode?: boolean;
userOverride?: UserOverride; userOverride?: UserOverride;
rowCount?: number;
dataLimitStatus?: DataLimitStatus; dataLimitStatus?: DataLimitStatus;
} }

6
app/common/Usage.ts Normal file
View File

@ -0,0 +1,6 @@
export type RowCount = number | 'hidden' | 'pending';
export type DataLimitStatus = null | 'approachingLimit' | 'gracePeriod' | 'deleteOnly';
// Ratio of the row/data size limit where we tell users that they're approaching the limit.
export const APPROACHING_LIMIT_RATIO = 0.9;

View File

@ -50,6 +50,11 @@ export function capitalize(str: string): string {
return str.replace(/\b[a-z]/gi, c => c.toUpperCase()); return str.replace(/\b[a-z]/gi, c => c.toUpperCase());
} }
// Capitalizes the first word in a string.
export function capitalizeFirstWord(str: string): string {
return str.replace(/\b[a-z]/i, c => c.toUpperCase());
}
// Returns whether the string n represents a valid number. // Returns whether the string n represents a valid number.
// http://stackoverflow.com/questions/18082/validate-numbers-in-javascript-isnumeric // http://stackoverflow.com/questions/18082/validate-numbers-in-javascript-isnumeric
export function isNumber(n: string): boolean { export function isNumber(n: string): boolean {

View File

@ -10,7 +10,6 @@ import {ActionSummary} from "app/common/ActionSummary";
import { import {
ApplyUAOptions, ApplyUAOptions,
ApplyUAResult, ApplyUAResult,
DataLimitStatus,
DataSourceTransformed, DataSourceTransformed,
ForkResult, ForkResult,
ImportOptions, ImportOptions,
@ -41,9 +40,11 @@ import {Features} from 'app/common/Features';
import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause'; import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause';
import {byteString, countIf, safeJsonParse} from 'app/common/gutil'; import {byteString, countIf, safeJsonParse} from 'app/common/gutil';
import {InactivityTimer} from 'app/common/InactivityTimer'; import {InactivityTimer} from 'app/common/InactivityTimer';
import {canEdit} from 'app/common/roles';
import {schema, SCHEMA_VERSION} from 'app/common/schema'; import {schema, SCHEMA_VERSION} from 'app/common/schema';
import {MetaRowRecord} from 'app/common/TableData'; import {MetaRowRecord} from 'app/common/TableData';
import {FetchUrlOptions, UploadResult} from 'app/common/uploads'; import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
import {APPROACHING_LIMIT_RATIO, DataLimitStatus, RowCount} from 'app/common/Usage';
import {DocReplacementOptions, DocState, DocStateComparison} from 'app/common/UserAPI'; import {DocReplacementOptions, DocState, DocStateComparison} from 'app/common/UserAPI';
import {convertFromColumn} from 'app/common/ValueConverter'; import {convertFromColumn} from 'app/common/ValueConverter';
import {guessColInfoWithDocData} from 'app/common/ValueGuesser'; import {guessColInfoWithDocData} from 'app/common/ValueGuesser';
@ -121,9 +122,6 @@ const REMOVE_UNUSED_ATTACHMENTS_INTERVAL_MS = 60 * 60 * 1000;
// A hook for dependency injection. // A hook for dependency injection.
export const Deps = {ACTIVEDOC_TIMEOUT}; export const Deps = {ACTIVEDOC_TIMEOUT};
// Ratio of the row/data size limit where we tell users that they're approaching the limit
const APPROACHING_LIMIT_RATIO = 0.9;
/** /**
* Represents an active document with the given name. The document isn't actually open until * Represents an active document with the given name. The document isn't actually open until
* either .loadDoc() or .createEmptyDoc() is called. * either .loadDoc() or .createEmptyDoc() is called.
@ -172,7 +170,7 @@ export class ActiveDoc extends EventEmitter {
private _lastMemoryMeasurement: number = 0; // Timestamp when memory was last measured. private _lastMemoryMeasurement: number = 0; // Timestamp when memory was last measured.
private _lastDataSizeMeasurement: number = 0; // Timestamp when dbstat data size was last measured. private _lastDataSizeMeasurement: number = 0; // Timestamp when dbstat data size was last measured.
private _fetchCache = new MapWithTTL<string, Promise<TableDataAction>>(DEFAULT_CACHE_TTL); private _fetchCache = new MapWithTTL<string, Promise<TableDataAction>>(DEFAULT_CACHE_TTL);
private _rowCount?: number; private _rowCount: RowCount = 'pending';
private _dataSize?: number; private _dataSize?: number;
private _productFeatures?: Features; private _productFeatures?: Features;
private _gracePeriodStart: Date|null = null; private _gracePeriodStart: Date|null = null;
@ -237,11 +235,21 @@ export class ActiveDoc extends EventEmitter {
public get isShuttingDown(): boolean { return this._shuttingDown; } public get isShuttingDown(): boolean { return this._shuttingDown; }
public get rowLimitRatio() { public get rowLimitRatio() {
return this._rowLimit && this._rowCount ? this._rowCount / this._rowLimit : 0; if (!this._rowLimit || this._rowLimit <= 0 || typeof this._rowCount !== 'number') {
// Invalid row limits are currently treated as if they are undefined.
return 0;
}
return this._rowCount / this._rowLimit;
} }
public get dataSizeLimitRatio() { public get dataSizeLimitRatio() {
return this._dataSizeLimit && this._dataSize ? this._dataSize / this._dataSizeLimit : 0; if (!this._dataSizeLimit || this._dataSizeLimit <= 0 || !this._dataSize) {
// Invalid data size limits are currently treated as if they are undefined.
return 0;
}
return this._dataSize / this._dataSizeLimit;
} }
public get dataLimitRatio() { public get dataLimitRatio() {
@ -264,15 +272,15 @@ export class ActiveDoc extends EventEmitter {
return null; return null;
} }
public async getRowCount(docSession: OptDocSession): Promise<number | undefined> { public async getRowCount(docSession: OptDocSession): Promise<RowCount> {
if (await this._granularAccess.canReadEverything(docSession)) { const hasFullReadAccess = await this._granularAccess.canReadEverything(docSession);
return this._rowCount; const hasEditRole = canEdit(await this._granularAccess.getNominalAccess(docSession));
} return hasFullReadAccess && hasEditRole ? this._rowCount : 'hidden';
} }
public async getDataLimitStatus(): Promise<DataLimitStatus> { public async getDataLimitStatus(docSession: OptDocSession): Promise<DataLimitStatus> {
// TODO filter based on session permissions const hasEditRole = canEdit(await this._granularAccess.getNominalAccess(docSession));
return this.dataLimitStatus; return hasEditRole ? this.dataLimitStatus : null;
} }
public async getUserOverride(docSession: OptDocSession) { public async getUserOverride(docSession: OptDocSession) {

View File

@ -318,7 +318,7 @@ export class DocManager extends EventEmitter {
activeDoc.getRecentMinimalActions(docSession), activeDoc.getRecentMinimalActions(docSession),
activeDoc.getUserOverride(docSession), activeDoc.getUserOverride(docSession),
activeDoc.getRowCount(docSession), activeDoc.getRowCount(docSession),
activeDoc.getDataLimitStatus(), activeDoc.getDataLimitStatus(docSession),
]); ]);
const result = { const result = {

View File

@ -244,7 +244,7 @@ export class GranularAccess implements GranularAccessForBundle {
// An alternative to this check would be to sandwich user-defined access rules // An alternative to this check would be to sandwich user-defined access rules
// between some defaults. Currently the defaults have lower priority than // between some defaults. Currently the defaults have lower priority than
// user-defined access rules. // user-defined access rules.
if (!canEdit(await this._getNominalAccess(docSession))) { if (!canEdit(await this.getNominalAccess(docSession))) {
throw new ErrorWithCode('ACL_DENY', 'Only owners or editors can modify documents'); throw new ErrorWithCode('ACL_DENY', 'Only owners or editors can modify documents');
} }
if (this._ruler.haveRules()) { if (this._ruler.haveRules()) {
@ -578,7 +578,7 @@ export class GranularAccess implements GranularAccessForBundle {
* permissions. * permissions.
*/ */
public async canReadEverything(docSession: OptDocSession): Promise<boolean> { public async canReadEverything(docSession: OptDocSession): Promise<boolean> {
const access = await this._getNominalAccess(docSession); const access = await this.getNominalAccess(docSession);
if (!canView(access)) { return false; } if (!canView(access)) { return false; }
const permInfo = await this._getAccess(docSession); const permInfo = await this._getAccess(docSession);
return this.getReadPermission(permInfo.getFullAccess()) === 'allow'; return this.getReadPermission(permInfo.getFullAccess()) === 'allow';
@ -621,7 +621,7 @@ export class GranularAccess implements GranularAccessForBundle {
* Check whether user has owner-level access to the document. * Check whether user has owner-level access to the document.
*/ */
public async isOwner(docSession: OptDocSession): Promise<boolean> { public async isOwner(docSession: OptDocSession): Promise<boolean> {
const access = await this._getNominalAccess(docSession); const access = await this.getNominalAccess(docSession);
return access === 'owners'; return access === 'owners';
} }
@ -769,6 +769,23 @@ export class GranularAccess implements GranularAccessForBundle {
return result; return result;
} }
/**
* Get the role the session user has for this document. User may be overridden,
* in which case the role of the override is returned.
* The forkingAsOwner flag of docSession should not be respected for non-owners,
* so that the pseudo-ownership it offers is restricted to granular access within a
* document (as opposed to document-level operations).
*/
public async getNominalAccess(docSession: OptDocSession): Promise<Role|null> {
const linkParameters = docSession.authorizer?.getLinkParameters() || {};
const baseAccess = getDocSessionAccess(docSession);
if ((linkParameters.aclAsUserId || linkParameters.aclAsUser) && baseAccess === 'owners') {
const info = await this._getUser(docSession);
return info.Access;
}
return baseAccess;
}
// AddOrUpdateRecord requires broad read access to a table. // AddOrUpdateRecord requires broad read access to a table.
// But tables can be renamed, and access can be granted and removed // But tables can be renamed, and access can be granted and removed
// within a bundle. // within a bundle.
@ -824,23 +841,6 @@ export class GranularAccess implements GranularAccessForBundle {
} }
} }
/**
* Get the role the session user has for this document. User may be overridden,
* in which case the role of the override is returned.
* The forkingAsOwner flag of docSession should not be respected for non-owners,
* so that the pseudo-ownership it offers is restricted to granular access within a
* document (as opposed to document-level operations).
*/
private async _getNominalAccess(docSession: OptDocSession): Promise<Role> {
const linkParameters = docSession.authorizer?.getLinkParameters() || {};
const baseAccess = getDocSessionAccess(docSession);
if ((linkParameters.aclAsUserId || linkParameters.aclAsUser) && baseAccess === 'owners') {
const info = await this._getUser(docSession);
return info.Access as Role;
}
return baseAccess;
}
/** /**
* Asserts that user has schema access. * Asserts that user has schema access.
*/ */