diff --git a/app/client/components/DocUsageBanner.ts b/app/client/components/DocUsageBanner.ts new file mode 100644 index 0000000000..ce2250401f --- /dev/null +++ b/app/client/components/DocUsageBanner.ts @@ -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 = + 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; + + 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; diff --git a/app/client/components/DocumentUsage.ts b/app/client/components/DocumentUsage.ts index 8165bd1e59..72d4239821 100644 --- a/app/client/components/DocumentUsage.ts +++ b/app/client/components/DocumentUsage.ts @@ -3,87 +3,196 @@ import {docListHeader} from 'app/client/ui/DocMenuCss'; import {colors, mediaXSmall} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; 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 {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, string> = { - approachingLimit: 'This document is approaching free plan limits.', - deleteOnly: 'This document is now in delete-only mode.', - gracePeriod: 'This document has exceeded free plan limits.', -}; +const testId = makeTestId('test-doc-usage-'); + +// Default used by the progress bar to visually indicate row usage. +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. - * - * Currently only shows usage if current site is a free team site. */ 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 = + 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 = + Computed.create(this, this._currentDoc, this._rowCount, (_use, doc, rowCount) => { + return doc === null || rowCount === 'pending'; + }); + + private readonly _isAccessDenied: Computed = + 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) { super(); } public buildDom() { - const features = this._docPageModel.appModel.currentFeatures; - if (features.baseMaxRowsPerDocument === undefined) { return null; } - return dom('div', - cssHeader('Usage'), - dom.domComputed(this._docPageModel.dataLimitStatus, status => { - if (!status) { return null; } - - return cssLimitWarning( - cssIcon('Idea'), - cssLightlyBoldedText( - limitStatusMessages[status], - ' For higher limits, ', - cssUnderlinedLink('start your 30-day free trial of the Pro plan.', { - href: commonUrls.plans, - target: '_blank', - }), - ), - ); + cssHeader('Usage', testId('heading')), + dom.domComputed(this._isLoading, (isLoading) => { + if (isLoading) { return cssSpinner(loadingSpinner(), testId('loading')); } + + return [this._buildMessage(), this._buildMetrics()]; }), + 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( - dom.create(buildUsageMetric, { - name: 'Rows', - currentValue: this._docPageModel.rowCount, - maximumValue: features.baseMaxRowsPerDocument, - units: 'rows', - }), - ) + dom.maybe(this._rowMetrics, (metrics) => + buildUsageMetric(metrics, testId('rows')), + ), + testId('metrics'), + ), ); } } +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 - * 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`. */ -function buildUsageMetric(owner: IDisposableOwner, {name, currentValue, maximumValue, units}: { - name: string; - currentValue: Observable; - maximumValue: number; - units?: string; -}) { - const percentUsed = Computed.create(owner, currentValue, (_use, value) => { - return Math.min(100, Math.floor(((value ?? 0) / maximumValue) * 100)); - }); +function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) { + const {name, currentValue, maximumValue, unit, shouldHideLimits} = options; + const ratioUsed = currentValue / (maximumValue || Infinity); + const percentUsed = Math.min(100, Math.floor(ratioUsed * 100)); return cssUsageMetric( - cssMetricName(name), + cssMetricName(name, testId('name')), cssProgressBarContainer( cssProgressBarFill( - dom.style('width', use => `${use(percentUsed)}%`), - cssProgressBarFill.cls('-approaching-limit', use => use(percentUsed) >= 90) - ) + {style: `width: ${percentUsed}%`}, + // 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', `${value} of ${maximumValue}` + (units ? ` ${units}` : '')) + dom('div', + currentValue + + (shouldHideLimits || !maximumValue ? '' : ' of ' + maximumValue) + + (unit ? ` ${unit}` : ''), + testId('value'), ), + ...domArgs, ); } +export function getLimitStatusMessage(status: NonNullable, 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', ` font-weight: 500; `); @@ -93,7 +202,7 @@ const cssIconAndText = styled('div', ` gap: 16px; `); -const cssLimitWarning = styled(cssIconAndText, ` +const cssWarningMessage = styled(cssIconAndText, ` margin-top: 16px; `); @@ -112,7 +221,6 @@ const cssHeader = styled(docListHeader, ` `); const cssUnderlinedLink = styled(cssLink, ` - display: inline-block; color: unset; text-decoration: underline; @@ -161,3 +269,9 @@ const cssProgressBarFill = styled(cssProgressBarContainer, ` background: ${colors.error}; } `); + +const cssSpinner = styled('div', ` + display: flex; + justify-content: center; + margin-top: 32px; +`); diff --git a/app/client/lib/localStorageObs.ts b/app/client/lib/localStorageObs.ts index a7aa3e9749..e5356a7946 100644 --- a/app/client/lib/localStorageObs.ts +++ b/app/client/lib/localStorageObs.ts @@ -24,17 +24,34 @@ export function getStorage(): Storage { 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 _sessionStorage: Storage|undefined; function createStorage(): Storage { if (typeof localStorage !== 'undefined' && testStorage(localStorage)) { return localStorage; + } else { + return createSessionStorage(); } +} + +function createSessionStorage(): Storage { if (typeof sessionStorage !== 'undefined' && testStorage(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(); return { 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. * @@ -53,11 +77,14 @@ function createStorage(): Storage { * same default value should be used for an observable every time it's created. */ export function localStorageBoolObs(key: string, defValue = false): Observable { - const store = getStorage(); - 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; + return getStorageBoolObs(getStorage(), key, defValue); +} + +/** + * Similar to `localStorageBoolObs`, but always uses sessionStorage (or an in-memory equivalent). + */ +export function sessionStorageBoolObs(key: string, defValue = false): Observable { + return getStorageBoolObs(getSessionStorage(), key, defValue); } /** diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index bb397c743f..7f8bd61bb2 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -14,13 +14,13 @@ import {bigBasicButton} from 'app/client/ui2018/buttons'; import {testId} from 'app/client/ui2018/cssVars'; import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018/menus'; import {confirmModal} from 'app/client/ui2018/modals'; -import {DataLimitStatus} from 'app/common/ActiveDocAPI'; import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow'; import {delay} from 'app/common/delay'; import {OpenDocMode, UserOverride} from 'app/common/DocListAPI'; import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls'; import {getReconnectTimeout} from 'app/common/gutil'; 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 {Holder, Observable, subscribe} from 'grainjs'; import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs'; @@ -66,7 +66,7 @@ export interface DocPageModel { gristDoc: Observable; // Instance of GristDoc once it exists. - rowCount: Observable; + rowCount: Observable; dataLimitStatus: Observable; createLeftPane(leftPanelOpen: Observable): DomArg; @@ -109,7 +109,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { // Observable set to the instance of GristDoc once it's created. public readonly gristDoc = Observable.create(this, null); - public readonly rowCount = Observable.create(this, undefined); + public readonly rowCount = Observable.create(this, 'pending'); public readonly dataLimitStatus = Observable.create(this, null); // Combination of arguments needed to open a doc (docOrUrlId + openMod). It's obtained from the diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 629a28d17c..9b4c952b01 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -1,3 +1,4 @@ +import {DocUsageBanner} from 'app/client/components/DocUsageBanner'; import {domAsync} from 'app/client/lib/domAsync'; import {loadBillingPage} from 'app/client/lib/imports'; 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()), onResize, testId, - contentBottom: dom.create(createBottomBarDoc, pageModel, leftPanelOpen, rightPanelOpen) + contentTop: dom.create(DocUsageBanner, pageModel), + contentBottom: dom.create(createBottomBarDoc, pageModel, leftPanelOpen, rightPanelOpen), }); } diff --git a/app/client/ui/PagePanels.ts b/app/client/ui/PagePanels.ts index 81a12aa814..fc5100b910 100644 --- a/app/client/ui/PagePanels.ts +++ b/app/client/ui/PagePanels.ts @@ -28,6 +28,7 @@ export interface PageContents { onResize?: () => void; // Callback for when either pane is opened, closed, or resized. testId?: TestId; + contentTop?: DomArg; contentBottom?: DomArg; } @@ -62,119 +63,122 @@ export function pagePanels(page: PageContents) { return cssPageContainer( dom.autoDispose(sub1), dom.autoDispose(sub2), - cssLeftPane( - testId('left-panel'), - cssTopHeader(left.header), - left.content, + page.contentTop, + cssContentMain( + cssLeftPane( + 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. - cssLeftPane.cls('-open', left.panelOpen), - transition(use => (use(isNarrowScreenObs()) ? false : use(left.panelOpen)), { - prepare(elem, open) { elem.style.marginRight = (open ? -1 : 1) * (left.panelWidth.get() - 48) + 'px'; }, - run(elem, open) { elem.style.marginRight = ''; }, - finish: onResize, - }), - ), + // Opening/closing the left pane, with transitions. + cssLeftPane.cls('-open', left.panelOpen), + transition(use => (use(isNarrowScreenObs()) ? false : use(left.panelOpen)), { + prepare(elem, open) { elem.style.marginRight = (open ? -1 : 1) * (left.panelWidth.get() - 48) + 'px'; }, + 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('')), + // 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('')), + // 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('')) - ), + 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, + 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('')) + (!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'), ), - 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('')), + (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, + cssRightPane( + testId('right-panel'), + cssTopHeader(right.header), + right.content, - dom.style('width', (use) => use(right.panelOpen) ? use(right.panelWidth) + 'px' : ''), + 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, + // 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); } }), - )] : 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( - cssPanelOpenerNarrowScreen( - 'FieldTextbox', - dom.on('click', () => { - right?.panelOpen.set(false); - toggleObs(left.panelOpen); - }), - testId('left-opener-ns') - ), - cssPanelOpenerNarrowScreenBtn.cls('-open', left.panelOpen) - ), - page.contentBottom, - (!right ? null : + testId('overlay') + ), + dom.maybe(isNarrowScreenObs(), () => + cssBottomFooter( + testId('bottom-footer'), cssPanelOpenerNarrowScreenBtn( cssPanelOpenerNarrowScreen( - 'Settings', + 'FieldTextbox', dom.on('click', () => { - left.panelOpen.set(false); - toggleObs(right.panelOpen); + right?.panelOpen.set(false); + 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', ` display: flex; `); -const cssPageContainer = styled(cssHBox, ` +const cssPageContainer = styled(cssVBox, ` position: absolute; 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 */ - overflow: hidden; top: 0; left: 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, ` position: relative; background-color: ${colors.lightGrey}; diff --git a/app/client/ui2018/modals.ts b/app/client/ui2018/modals.ts index db77f9c93c..c1bc437ff8 100644 --- a/app/client/ui2018/modals.ts +++ b/app/client/ui2018/modals.ts @@ -365,6 +365,7 @@ export const cssModalTitle = styled('div', ` color: ${colors.dark}; margin: 0 0 16px 0; line-height: 32px; + overflow-wrap: break-word; `); export const cssModalBody = styled('div', ` diff --git a/app/common/ActiveDocAPI.ts b/app/common/ActiveDocAPI.ts index 672fa13522..b81b792824 100644 --- a/app/common/ActiveDocAPI.ts +++ b/app/common/ActiveDocAPI.ts @@ -153,8 +153,6 @@ export interface PermissionDataWithExtraUsers extends PermissionData { exampleUsers: UserAccessData[]; } -export type DataLimitStatus = null | 'approachingLimit' | 'gracePeriod' | 'deleteOnly'; - export interface ActiveDocAPI { /** * Closes a document, and unsubscribes from its userAction events. diff --git a/app/common/DocListAPI.ts b/app/common/DocListAPI.ts index 1cb40e7de5..3c09de45d8 100644 --- a/app/common/DocListAPI.ts +++ b/app/common/DocListAPI.ts @@ -1,8 +1,8 @@ import {MinimalActionGroup} from 'app/common/ActionGroup'; -import {DataLimitStatus} from 'app/common/ActiveDocAPI'; import {TableDataAction} from 'app/common/DocActions'; import {Role} from 'app/common/roles'; import {StringUnion} from 'app/common/StringUnion'; +import {DataLimitStatus, RowCount} from 'app/common/Usage'; import {FullUser} from 'app/common/UserAPI'; // 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 doc: {[tableId: string]: TableDataAction}; log: MinimalActionGroup[]; + rowCount: RowCount; recoveryMode?: boolean; userOverride?: UserOverride; - rowCount?: number; dataLimitStatus?: DataLimitStatus; } diff --git a/app/common/Usage.ts b/app/common/Usage.ts new file mode 100644 index 0000000000..e9042dfe09 --- /dev/null +++ b/app/common/Usage.ts @@ -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; diff --git a/app/common/gutil.ts b/app/common/gutil.ts index afa64952b5..dc5796e64f 100644 --- a/app/common/gutil.ts +++ b/app/common/gutil.ts @@ -50,6 +50,11 @@ export function capitalize(str: string): string { 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. // http://stackoverflow.com/questions/18082/validate-numbers-in-javascript-isnumeric export function isNumber(n: string): boolean { diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 868ca776f0..bff1769afc 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -10,7 +10,6 @@ import {ActionSummary} from "app/common/ActionSummary"; import { ApplyUAOptions, ApplyUAResult, - DataLimitStatus, DataSourceTransformed, ForkResult, ImportOptions, @@ -41,9 +40,11 @@ import {Features} from 'app/common/Features'; import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause'; import {byteString, countIf, safeJsonParse} from 'app/common/gutil'; import {InactivityTimer} from 'app/common/InactivityTimer'; +import {canEdit} from 'app/common/roles'; import {schema, SCHEMA_VERSION} from 'app/common/schema'; import {MetaRowRecord} from 'app/common/TableData'; 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 {convertFromColumn} from 'app/common/ValueConverter'; import {guessColInfoWithDocData} from 'app/common/ValueGuesser'; @@ -121,9 +122,6 @@ const REMOVE_UNUSED_ATTACHMENTS_INTERVAL_MS = 60 * 60 * 1000; // A hook for dependency injection. 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 * 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 _lastDataSizeMeasurement: number = 0; // Timestamp when dbstat data size was last measured. private _fetchCache = new MapWithTTL>(DEFAULT_CACHE_TTL); - private _rowCount?: number; + private _rowCount: RowCount = 'pending'; private _dataSize?: number; private _productFeatures?: Features; private _gracePeriodStart: Date|null = null; @@ -237,11 +235,21 @@ export class ActiveDoc extends EventEmitter { public get isShuttingDown(): boolean { return this._shuttingDown; } 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() { - 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() { @@ -264,15 +272,15 @@ export class ActiveDoc extends EventEmitter { return null; } - public async getRowCount(docSession: OptDocSession): Promise { - if (await this._granularAccess.canReadEverything(docSession)) { - return this._rowCount; - } + public async getRowCount(docSession: OptDocSession): Promise { + const hasFullReadAccess = await this._granularAccess.canReadEverything(docSession); + const hasEditRole = canEdit(await this._granularAccess.getNominalAccess(docSession)); + return hasFullReadAccess && hasEditRole ? this._rowCount : 'hidden'; } - public async getDataLimitStatus(): Promise { - // TODO filter based on session permissions - return this.dataLimitStatus; + public async getDataLimitStatus(docSession: OptDocSession): Promise { + const hasEditRole = canEdit(await this._granularAccess.getNominalAccess(docSession)); + return hasEditRole ? this.dataLimitStatus : null; } public async getUserOverride(docSession: OptDocSession) { diff --git a/app/server/lib/DocManager.ts b/app/server/lib/DocManager.ts index 0279d0074a..a99750c0ce 100644 --- a/app/server/lib/DocManager.ts +++ b/app/server/lib/DocManager.ts @@ -318,7 +318,7 @@ export class DocManager extends EventEmitter { activeDoc.getRecentMinimalActions(docSession), activeDoc.getUserOverride(docSession), activeDoc.getRowCount(docSession), - activeDoc.getDataLimitStatus(), + activeDoc.getDataLimitStatus(docSession), ]); const result = { diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index 2eeedf57d1..1a68779257 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -244,7 +244,7 @@ export class GranularAccess implements GranularAccessForBundle { // An alternative to this check would be to sandwich user-defined access rules // between some defaults. Currently the defaults have lower priority than // 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'); } if (this._ruler.haveRules()) { @@ -578,7 +578,7 @@ export class GranularAccess implements GranularAccessForBundle { * permissions. */ public async canReadEverything(docSession: OptDocSession): Promise { - const access = await this._getNominalAccess(docSession); + const access = await this.getNominalAccess(docSession); if (!canView(access)) { return false; } const permInfo = await this._getAccess(docSession); 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. */ public async isOwner(docSession: OptDocSession): Promise { - const access = await this._getNominalAccess(docSession); + const access = await this.getNominalAccess(docSession); return access === 'owners'; } @@ -769,6 +769,23 @@ export class GranularAccess implements GranularAccessForBundle { 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 { + 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. // But tables can be renamed, and access can be granted and removed // 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 { - 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. */