Skip to content

Commit

Permalink
(core) Add attachment and data size usage
Browse files Browse the repository at this point in the history
Summary:
Adds attachment and data size to the usage section of
the raw data page. Also makes in-document usage banners
update as user actions are applied, causing them to be
hidden/shown or updated based on the current state of
the document.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: alexmojaki

Differential Revision: https://phab.getgrist.com/D3395
  • Loading branch information
georgegevoian committed May 4, 2022
1 parent f194d68 commit 1e42871
Show file tree
Hide file tree
Showing 18 changed files with 379 additions and 153 deletions.
2 changes: 2 additions & 0 deletions app/client/components/DocComm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {ActionGroup} from 'app/common/ActionGroup';
import {ActiveDocAPI, ApplyUAOptions, ApplyUAResult} from 'app/common/ActiveDocAPI';
import {DocAction, UserAction} from 'app/common/DocActions';
import {OpenLocalDocResult} from 'app/common/DocListAPI';
import {DocUsage} from 'app/common/DocUsage';
import {docUrl} from 'app/common/urlUtils';
import {Events as BackboneEvents} from 'backbone';
import {Disposable, Emitter} from 'grainjs';
Expand All @@ -17,6 +18,7 @@ export interface DocUserAction extends CommMessage {
data: {
docActions: DocAction[];
actionGroup: ActionGroup;
docUsage: DocUsage;
error?: string;
};
}
Expand Down
6 changes: 3 additions & 3 deletions app/client/components/DocUsageBanner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {buildUpgradeMessage, getLimitStatusMessage} from 'app/client/components/DocumentUsage';
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';
Expand Down Expand Up @@ -66,7 +66,7 @@ export class DocUsageBanner extends Disposable {
cssBannerMessage(
cssWhiteIcon('Idea'),
cssLightlyBoldedText(
getLimitStatusMessage('approachingLimit', features),
buildLimitStatusMessage('approachingLimit', features),
' ',
buildUpgradeMessage(org.access === 'owners'),
testId('text'),
Expand Down Expand Up @@ -99,7 +99,7 @@ export class DocUsageBanner extends Disposable {
}

return [
getLimitStatusMessage(isDeleteOnly ? 'deleteOnly' : 'gracePeriod', features),
buildLimitStatusMessage(isDeleteOnly ? 'deleteOnly' : 'gracePeriod', features),
' ',
buildUpgradeMessage(isOwner),
];
Expand Down
206 changes: 150 additions & 56 deletions app/client/components/DocumentUsage.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import {DocPageModel} from 'app/client/models/DocPageModel';
import {urlState} from 'app/client/models/gristUrlState';
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 {loadingSpinner} from 'app/client/ui2018/loaders';
import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/DocUsage';
import {Features} from 'app/common/Features';
import {commonUrls} from 'app/common/gristUrls';
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 testId = makeTestId('test-doc-usage-');

// Default used by the progress bar to visually indicate row usage.
const DEFAULT_MAX_ROWS = 20000;

// Default used by the progress bar to visually indicate data size usage.
const DEFAULT_MAX_DATA_SIZE = DEFAULT_MAX_ROWS * 2 * 1024; // 40MB (2KiB per row)

// Default used by the progress bar to visually indicate attachments size usage.
const DEFAULT_MAX_ATTACHMENTS_SIZE = 1 * 1024 * 1024 * 1024; // 1GiB

const ACCESS_DENIED_MESSAGE = 'Usage statistics are only available to users with '
+ 'full access to the document data.';

Expand All @@ -25,6 +32,8 @@ 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 _dataSizeBytes = this._docPageModel.dataSizeBytes;
private readonly _attachmentsSizeBytes = this._docPageModel.attachmentsSizeBytes;

private readonly _currentOrg = Computed.create(this, this._currentDoc, (_use, doc) => {
return doc?.workspace.org ?? null;
Expand All @@ -47,20 +56,65 @@ export class DocumentUsage extends Disposable {
};
});

private readonly _isLoading: Computed<boolean> =
Computed.create(this, this._currentDoc, this._rowCount, (_use, doc, rowCount) => {
return doc === null || rowCount === 'pending';
private readonly _dataSizeMetrics: Computed<MetricOptions | null> =
Computed.create(this, this._currentOrg, this._dataSizeBytes, (_use, org, dataSize) => {
const features = org?.billingAccount?.product.features;
if (!features || typeof dataSize !== 'number') { return null; }

const {baseMaxDataSizePerDocument: maxSize} = features;
// Invalid data size limits are currently treated as if they are undefined.
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
return {
name: 'Data Size',
currentValue: dataSize,
maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE,
unit: 'MB',
shouldHideLimits: maxValue === undefined,
formatValue: (val) => {
// To display a nice, round number for `maximumValue`, we first convert
// to KiBs (base-2), and then convert to MBs (base-10). Normally, we wouldn't
// mix conversions like this, but to display something that matches our
// marketing limits (e.g. 40MB for Pro plan), we need to bend conversions a bit.
return ((val / 1024) / 1000).toFixed(2);
},
};
});

private readonly _attachmentsSizeMetrics: Computed<MetricOptions | null> =
Computed.create(this, this._currentOrg, this._attachmentsSizeBytes, (_use, org, attachmentsSize) => {
const features = org?.billingAccount?.product.features;
if (!features || typeof attachmentsSize !== 'number') { return null; }

const {baseMaxAttachmentsBytesPerDocument: maxSize} = features;
// Invalid attachments size limits are currently treated as if they are undefined.
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
return {
name: 'Attachments Size',
currentValue: attachmentsSize,
maximumValue: maxValue ?? DEFAULT_MAX_ATTACHMENTS_SIZE,
unit: 'GB',
shouldHideLimits: maxValue === undefined,
formatValue: (val) => (val / (1024 * 1024 * 1024)).toFixed(2),
};
});

private readonly _isLoading: Computed<boolean> =
Computed.create(
this, this._currentDoc, this._rowCount, this._dataSizeBytes, this._attachmentsSizeBytes,
(_use, doc, rowCount, dataSize, attachmentsSize) => {
return !doc || [rowCount, dataSize, attachmentsSize].some(metric => metric === 'pending');
}
);

private readonly _isAccessDenied: Computed<boolean | null> =
Computed.create(
this, this._isLoading, this._currentDoc, this._rowCount,
(_use, isLoading, doc, rowCount) => {
this, this._isLoading, this._currentDoc, this._rowCount, this._dataSizeBytes, this._attachmentsSizeBytes,
(_use, isLoading, doc, rowCount, dataSize, attachmentsSize) => {
if (isLoading) { return null; }

const {access} = doc!.workspace.org;
const isPublicUser = access === 'guests' || access === null;
return isPublicUser || rowCount === 'hidden';
return isPublicUser || [rowCount, dataSize, attachmentsSize].some(metric => metric === 'hidden');
}
);

Expand Down Expand Up @@ -91,7 +145,9 @@ export class DocumentUsage extends Disposable {
if (!org || !status) { return null; }

return buildMessage([
getLimitStatusMessage(status, org.billingAccount?.product.features),
buildLimitStatusMessage(status, org.billingAccount?.product.features, {
disableRawDataLink: true
}),
' ',
buildUpgradeMessage(org.access === 'owners')
]);
Expand All @@ -104,18 +160,79 @@ export class DocumentUsage extends Disposable {
dom.maybe(this._rowMetrics, (metrics) =>
buildUsageMetric(metrics, testId('rows')),
),
dom.maybe(this._dataSizeMetrics, (metrics) =>
buildUsageMetric(metrics, testId('data-size')),
),
dom.maybe(this._attachmentsSizeMetrics, (metrics) =>
buildUsageMetric(metrics, testId('attachments-size')),
),
testId('metrics'),
),
);
}
}

function buildMessage(message: DomContents) {
return cssWarningMessage(
cssIcon('Idea'),
cssLightlyBoldedText(message, testId('message-text')),
testId('message'),
);
export function buildLimitStatusMessage(
status: NonNullable<DataLimitStatus>,
features?: Features,
options: {
disableRawDataLink?: boolean;
} = {}
) {
const {disableRawDataLink = false} = options;
switch (status) {
case 'approachingLimit': {
return [
'This document is ',
disableRawDataLink ? 'approaching' : buildRawDataPageLink('approaching'),
' free plan limits.'
];
}
case 'gracePeriod': {
const gracePeriodDays = features?.gracePeriodDays;
if (!gracePeriodDays) {
return [
'Document limits ',
disableRawDataLink ? 'exceeded' : buildRawDataPageLink('exceeded'),
'.'
];
}

return [
'Document limits ',
disableRawDataLink ? 'exceeded' : buildRawDataPageLink('exceeded'),
`. In ${gracePeriodDays} days, this document will be read-only.`
];
}
case 'deleteOnly': {
return [
'This document ',
disableRawDataLink ? 'exceeded' : buildRawDataPageLink('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),
];
}

function buildUpgradeLink(linkText: string) {
return cssUnderlinedLink(linkText, {
href: commonUrls.plans,
target: '_blank',
});
}

function buildRawDataPageLink(linkText: string) {
return cssUnderlinedLink(linkText, urlState().setLinkUrl({docPage: 'data'}));
}

interface MetricOptions {
Expand All @@ -126,6 +243,7 @@ interface MetricOptions {
unit?: string;
// If true, limits will always be hidden, even if `maximumValue` is a positive number.
shouldHideLimits?: boolean;
formatValue?(value: number): string;
}

/**
Expand All @@ -134,7 +252,14 @@ interface MetricOptions {
* close `currentValue` is to hitting `maximumValue`.
*/
function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) {
const {name, currentValue, maximumValue, unit, shouldHideLimits} = options;
const {
name,
currentValue,
maximumValue,
unit,
shouldHideLimits,
formatValue = (val) => val.toString(),
} = options;
const ratioUsed = currentValue / (maximumValue || Infinity);
const percentUsed = Math.min(100, Math.floor(ratioUsed * 100));
return cssUsageMetric(
Expand All @@ -150,47 +275,21 @@ function buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) {
),
),
dom('div',
currentValue
+ (shouldHideLimits || !maximumValue ? '' : ' of ' + maximumValue)
formatValue(currentValue)
+ (shouldHideLimits || !maximumValue ? '' : ' of ' + formatValue(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',
});
function buildMessage(message: DomContents) {
return cssWarningMessage(
cssIcon('Idea'),
cssLightlyBoldedText(message, testId('message-text')),
testId('message'),
);
}

const cssLightlyBoldedText = styled('div', `
Expand Down Expand Up @@ -233,13 +332,8 @@ const cssUsageMetrics = styled('div', `
display: flex;
flex-wrap: wrap;
margin-top: 24px;
gap: 56px;
@media ${mediaXSmall} {
& {
gap: 24px;
}
}
row-gap: 24px;
column-gap: 54px;
`);

const cssUsageMetric = styled('div', `
Expand Down
4 changes: 1 addition & 3 deletions app/client/components/GristDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,9 +478,7 @@ export class GristDoc extends DisposableWithEvents {
if (schemaUpdated) {
this.trigger('schemaUpdateAction', docActions);
}
if (typeof actionGroup.rowCount === "number") {
this.docPageModel.rowCount.set(actionGroup.rowCount);
}
this.docPageModel.updateDocUsage(message.data.docUsage);
}
}

Expand Down
Loading

0 comments on commit 1e42871

Please sign in to comment.