mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) DocLimits: display days remaining instead of days of grace period
Summary: Before this change we would always say there are 14 days remaining, regardless of how many actually are remaining. Let's pass around a different `dataLimitsInfo` object that also reports the number of days remaining. Test Plan: Ensure the test suite passes. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4332
This commit is contained in:
parent
8da89b0a3d
commit
80f8168cab
@ -7,7 +7,7 @@ import {withInfoTooltip} from 'app/client/ui/tooltips';
|
|||||||
import {mediaXSmall, theme} from 'app/client/ui2018/cssVars';
|
import {mediaXSmall, theme} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {loadingDots, loadingSpinner} from 'app/client/ui2018/loaders';
|
import {loadingDots, loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/DocUsage';
|
import {APPROACHING_LIMIT_RATIO, DataLimitInfo} from 'app/common/DocUsage';
|
||||||
import {Features, isFreePlan} from 'app/common/Features';
|
import {Features, isFreePlan} from 'app/common/Features';
|
||||||
import {capitalizeFirstWord} from 'app/common/gutil';
|
import {capitalizeFirstWord} from 'app/common/gutil';
|
||||||
import {canUpgradeOrg} from 'app/common/roles';
|
import {canUpgradeOrg} from 'app/common/roles';
|
||||||
@ -40,8 +40,8 @@ export class DocumentUsage extends Disposable {
|
|||||||
// TODO: Update this whenever the rest of the UI is internationalized.
|
// TODO: Update this whenever the rest of the UI is internationalized.
|
||||||
private readonly _rowCountFormatter = new Intl.NumberFormat('en-US');
|
private readonly _rowCountFormatter = new Intl.NumberFormat('en-US');
|
||||||
|
|
||||||
private readonly _dataLimitStatus = Computed.create(this, this._currentDocUsage, (_use, usage) => {
|
private readonly _dataLimitInfo = Computed.create(this, this._currentDocUsage, (_use, usage) => {
|
||||||
return usage?.dataLimitStatus ?? null;
|
return usage?.dataLimitInfo;
|
||||||
});
|
});
|
||||||
|
|
||||||
private readonly _rowCount = Computed.create(this, this._currentDocUsage, (_use, usage) => {
|
private readonly _rowCount = Computed.create(this, this._currentDocUsage, (_use, usage) => {
|
||||||
@ -158,11 +158,11 @@ export class DocumentUsage extends Disposable {
|
|||||||
const org = use(this._currentOrg);
|
const org = use(this._currentOrg);
|
||||||
const product = use(this._currentProduct);
|
const product = use(this._currentProduct);
|
||||||
const features = use(this._currentFeatures);
|
const features = use(this._currentFeatures);
|
||||||
const status = use(this._dataLimitStatus);
|
const usageInfo = use(this._dataLimitInfo);
|
||||||
if (!org || !status) { return null; }
|
if (!org || !usageInfo?.status) { return null; }
|
||||||
|
|
||||||
return buildMessage([
|
return buildMessage([
|
||||||
buildLimitStatusMessage(status, features, {
|
buildLimitStatusMessage(usageInfo, features, {
|
||||||
disableRawDataLink: true
|
disableRawDataLink: true
|
||||||
}),
|
}),
|
||||||
(product && isFreePlan(product.name)
|
(product && isFreePlan(product.name)
|
||||||
@ -196,13 +196,14 @@ export class DocumentUsage extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildLimitStatusMessage(
|
export function buildLimitStatusMessage(
|
||||||
status: NonNullable<DataLimitStatus>,
|
usageInfo: NonNullable<DataLimitInfo>,
|
||||||
features?: Features|null,
|
features?: Features|null,
|
||||||
options: {
|
options: {
|
||||||
disableRawDataLink?: boolean;
|
disableRawDataLink?: boolean;
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
const {disableRawDataLink = false} = options;
|
const {disableRawDataLink = false} = options;
|
||||||
|
const {status, daysRemaining} = usageInfo;
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'approachingLimit': {
|
case 'approachingLimit': {
|
||||||
return [
|
return [
|
||||||
@ -224,7 +225,7 @@ export function buildLimitStatusMessage(
|
|||||||
return [
|
return [
|
||||||
'Document limits ',
|
'Document limits ',
|
||||||
disableRawDataLink ? 'exceeded' : buildRawDataPageLink('exceeded'),
|
disableRawDataLink ? 'exceeded' : buildRawDataPageLink('exceeded'),
|
||||||
`. In ${gracePeriodDays} days, this document will be read-only.`
|
`. In ${daysRemaining} days, this document will be read-only.`
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
case 'deleteOnly': {
|
case 'deleteOnly': {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {ApiError} from 'app/common/ApiError';
|
import {ApiError} from 'app/common/ApiError';
|
||||||
import {APPROACHING_LIMIT_RATIO, DataLimitStatus, DocumentUsage, getUsageRatio} from 'app/common/DocUsage';
|
import {APPROACHING_LIMIT_RATIO, DataLimitInfo, DataLimitStatus,
|
||||||
|
DocumentUsage, getUsageRatio} from 'app/common/DocUsage';
|
||||||
import {Features} from 'app/common/Features';
|
import {Features} from 'app/common/Features';
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
|
|
||||||
@ -22,22 +23,24 @@ export interface GetDataLimitStatusParams {
|
|||||||
* Given a set of params that includes document usage, current product features, and
|
* Given a set of params that includes document usage, current product features, and
|
||||||
* a grace-period start (if any), returns the data limit status of a document.
|
* a grace-period start (if any), returns the data limit status of a document.
|
||||||
*/
|
*/
|
||||||
export function getDataLimitStatus(params: GetDataLimitStatusParams): DataLimitStatus {
|
export function getDataLimitInfo(params: GetDataLimitStatusParams): DataLimitInfo {
|
||||||
const {docUsage, productFeatures, gracePeriodStart} = params;
|
const {docUsage, productFeatures, gracePeriodStart} = params;
|
||||||
const ratio = getDataLimitRatio(docUsage, productFeatures);
|
const ratio = getDataLimitRatio(docUsage, productFeatures);
|
||||||
if (ratio > 1) {
|
if (ratio > 1) {
|
||||||
const start = gracePeriodStart;
|
const start = gracePeriodStart;
|
||||||
const days = productFeatures?.gracePeriodDays;
|
// In case we forgot to define a grace period, we'll default to two weeks.
|
||||||
if (start && days && moment().diff(moment(start), 'days') >= days) {
|
const days = productFeatures?.gracePeriodDays ?? 14;
|
||||||
return 'deleteOnly';
|
const daysRemaining = start && days ? days - moment().diff(moment(start), 'days') : NaN;
|
||||||
|
if (daysRemaining > 0) {
|
||||||
|
return {status: 'gracePeriod', daysRemaining};
|
||||||
} else {
|
} else {
|
||||||
return 'gracePeriod';
|
return {status: 'deleteOnly'};
|
||||||
}
|
}
|
||||||
} else if (ratio > APPROACHING_LIMIT_RATIO) {
|
} else if (ratio > APPROACHING_LIMIT_RATIO) {
|
||||||
return 'approachingLimit';
|
return {status: 'approachingLimit'};
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {status: null};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -10,13 +10,17 @@ export interface RowCounts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DataLimitStatus = 'approachingLimit' | 'gracePeriod' | 'deleteOnly' | null;
|
export type DataLimitStatus = 'approachingLimit' | 'gracePeriod' | 'deleteOnly' | null;
|
||||||
|
export interface DataLimitInfo {
|
||||||
|
status: DataLimitStatus;
|
||||||
|
daysRemaining?: number;
|
||||||
|
}
|
||||||
|
|
||||||
type DocUsageOrPending = {
|
type DocUsageOrPending = {
|
||||||
[Metric in keyof Required<DocumentUsage>]: Required<DocumentUsage>[Metric] | 'pending'
|
[Metric in keyof Required<DocumentUsage>]: Required<DocumentUsage>[Metric] | 'pending'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocUsageSummary extends DocUsageOrPending {
|
export interface DocUsageSummary extends DocUsageOrPending {
|
||||||
dataLimitStatus: DataLimitStatus;
|
dataLimitInfo: DataLimitInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count of non-removed documents in an org, grouped by data limit status.
|
// Count of non-removed documents in an org, grouped by data limit status.
|
||||||
@ -27,7 +31,7 @@ type FilteredDocUsage = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FilteredDocUsageSummary extends FilteredDocUsage {
|
export interface FilteredDocUsageSummary extends FilteredDocUsage {
|
||||||
dataLimitStatus: DataLimitStatus;
|
dataLimitInfo: DataLimitInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ratio of usage at which we start telling users that they're approaching limits.
|
// Ratio of usage at which we start telling users that they're approaching limits.
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import {ShareInfo} from 'app/common/ActiveDocAPI';
|
import {ShareInfo} from 'app/common/ActiveDocAPI';
|
||||||
import {ApiError, LimitType} from 'app/common/ApiError';
|
import {ApiError, LimitType} from 'app/common/ApiError';
|
||||||
import {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
|
import {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
|
||||||
import {getDataLimitStatus} from 'app/common/DocLimits';
|
import {getDataLimitInfo} from 'app/common/DocLimits';
|
||||||
import {createEmptyOrgUsageSummary, DocumentUsage, OrgUsageSummary} from 'app/common/DocUsage';
|
import {createEmptyOrgUsageSummary, DocumentUsage, OrgUsageSummary} from 'app/common/DocUsage';
|
||||||
import {normalizeEmail} from 'app/common/emails';
|
import {normalizeEmail} from 'app/common/emails';
|
||||||
import {ANONYMOUS_PLAN, canAddOrgMembers, Features} from 'app/common/Features';
|
import {ANONYMOUS_PLAN, canAddOrgMembers, Features} from 'app/common/Features';
|
||||||
@ -764,7 +764,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
// Return an aggregate count of documents, grouped by data limit status.
|
// Return an aggregate count of documents, grouped by data limit status.
|
||||||
const summary = createEmptyOrgUsageSummary();
|
const summary = createEmptyOrgUsageSummary();
|
||||||
for (const {usage: docUsage, gracePeriodStart} of docs) {
|
for (const {usage: docUsage, gracePeriodStart} of docs) {
|
||||||
const dataLimitStatus = getDataLimitStatus({docUsage, gracePeriodStart, productFeatures});
|
const dataLimitStatus = getDataLimitInfo({docUsage, gracePeriodStart, productFeatures}).status;
|
||||||
if (dataLimitStatus) { summary[dataLimitStatus] += 1; }
|
if (dataLimitStatus) { summary[dataLimitStatus] += 1; }
|
||||||
}
|
}
|
||||||
return summary;
|
return summary;
|
||||||
|
@ -50,12 +50,12 @@ import {
|
|||||||
UserAction
|
UserAction
|
||||||
} from 'app/common/DocActions';
|
} from 'app/common/DocActions';
|
||||||
import {DocData} from 'app/common/DocData';
|
import {DocData} from 'app/common/DocData';
|
||||||
import {getDataLimitRatio, getDataLimitStatus, getSeverity, LimitExceededError} from 'app/common/DocLimits';
|
import {getDataLimitInfo, getDataLimitRatio, getSeverity, LimitExceededError} from 'app/common/DocLimits';
|
||||||
import {DocSnapshots} from 'app/common/DocSnapshot';
|
import {DocSnapshots} from 'app/common/DocSnapshot';
|
||||||
import {DocumentSettings} from 'app/common/DocumentSettings';
|
import {DocumentSettings} from 'app/common/DocumentSettings';
|
||||||
import {
|
import {
|
||||||
APPROACHING_LIMIT_RATIO,
|
APPROACHING_LIMIT_RATIO,
|
||||||
DataLimitStatus,
|
DataLimitInfo,
|
||||||
DocumentUsage,
|
DocumentUsage,
|
||||||
DocUsageSummary,
|
DocUsageSummary,
|
||||||
FilteredDocUsageSummary,
|
FilteredDocUsageSummary,
|
||||||
@ -421,8 +421,8 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
return getDataLimitRatio(this._docUsage, this._product?.features);
|
return getDataLimitRatio(this._docUsage, this._product?.features);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get dataLimitStatus(): DataLimitStatus {
|
public get dataLimitInfo(): DataLimitInfo {
|
||||||
return getDataLimitStatus({
|
return getDataLimitInfo({
|
||||||
docUsage: this._docUsage,
|
docUsage: this._docUsage,
|
||||||
productFeatures: this._product?.features,
|
productFeatures: this._product?.features,
|
||||||
gracePeriodStart: this._gracePeriodStart,
|
gracePeriodStart: this._gracePeriodStart,
|
||||||
@ -431,7 +431,7 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
|
|
||||||
public getDocUsageSummary(): DocUsageSummary {
|
public getDocUsageSummary(): DocUsageSummary {
|
||||||
return {
|
return {
|
||||||
dataLimitStatus: this.dataLimitStatus,
|
dataLimitInfo: this.dataLimitInfo,
|
||||||
rowCount: this._docUsage?.rowCount ?? 'pending',
|
rowCount: this._docUsage?.rowCount ?? 'pending',
|
||||||
dataSizeBytes: this._docUsage?.dataSizeBytes ?? 'pending',
|
dataSizeBytes: this._docUsage?.dataSizeBytes ?? 'pending',
|
||||||
attachmentsSizeBytes: this._docUsage?.attachmentsSizeBytes ?? 'pending',
|
attachmentsSizeBytes: this._docUsage?.attachmentsSizeBytes ?? 'pending',
|
||||||
@ -2176,7 +2176,7 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
await this.waitForInitialization();
|
await this.waitForInitialization();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.dataLimitStatus === "deleteOnly" &&
|
this.dataLimitInfo.status === "deleteOnly" &&
|
||||||
!actions.every(action => [
|
!actions.every(action => [
|
||||||
'RemoveTable', 'RemoveColumn', 'RemoveRecord', 'BulkRemoveRecord',
|
'RemoveTable', 'RemoveColumn', 'RemoveRecord', 'BulkRemoveRecord',
|
||||||
'RemoveViewSection', 'RemoveView', 'ApplyUndoActions', 'RespondToRequests',
|
'RemoveViewSection', 'RemoveView', 'ApplyUndoActions', 'RespondToRequests',
|
||||||
@ -2253,13 +2253,13 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private async _updateDocUsage(usage: Partial<DocumentUsage>, options: UpdateUsageOptions = {}) {
|
private async _updateDocUsage(usage: Partial<DocumentUsage>, options: UpdateUsageOptions = {}) {
|
||||||
const {syncUsageToDatabase = true, broadcastUsageToClients = true} = options;
|
const {syncUsageToDatabase = true, broadcastUsageToClients = true} = options;
|
||||||
const oldStatus = this.dataLimitStatus;
|
const oldStatus = this.dataLimitInfo.status;
|
||||||
this._docUsage = {...(this._docUsage || {}), ...usage};
|
this._docUsage = {...(this._docUsage || {}), ...usage};
|
||||||
if (syncUsageToDatabase) {
|
if (syncUsageToDatabase) {
|
||||||
/* If status decreased, we'll update usage in the database with minimal delay, so site usage
|
/* If status decreased, we'll update usage in the database with minimal delay, so site usage
|
||||||
* banners show up-to-date statistics. If status increased or stayed the same, we'll schedule
|
* banners show up-to-date statistics. If status increased or stayed the same, we'll schedule
|
||||||
* a delayed update, since it's less critical for banners to update immediately. */
|
* a delayed update, since it's less critical for banners to update immediately. */
|
||||||
const didStatusDecrease = getSeverity(this.dataLimitStatus) < getSeverity(oldStatus);
|
const didStatusDecrease = getSeverity(this.dataLimitInfo.status) < getSeverity(oldStatus);
|
||||||
this._syncDocUsageToDatabase(didStatusDecrease);
|
this._syncDocUsageToDatabase(didStatusDecrease);
|
||||||
}
|
}
|
||||||
if (broadcastUsageToClients) {
|
if (broadcastUsageToClients) {
|
||||||
|
@ -792,7 +792,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
}
|
}
|
||||||
const role = options.role ?? await this.getNominalAccess(docSession);
|
const role = options.role ?? await this.getNominalAccess(docSession);
|
||||||
const hasEditRole = canEdit(role);
|
const hasEditRole = canEdit(role);
|
||||||
if (!hasEditRole) { result.dataLimitStatus = null; }
|
if (!hasEditRole) { result.dataLimitInfo.status = null; }
|
||||||
const hasFullReadAccess = await this.canReadEverything(docSession);
|
const hasFullReadAccess = await this.canReadEverything(docSession);
|
||||||
if (!hasEditRole || !hasFullReadAccess) {
|
if (!hasEditRole || !hasFullReadAccess) {
|
||||||
result.rowCount = 'hidden';
|
result.rowCount = 'hidden';
|
||||||
|
@ -2212,6 +2212,7 @@ describe('ApiServer', function() {
|
|||||||
await server.dbManager.setDocsMetadata({[id]: {usage}});
|
await server.dbManager.setDocsMetadata({[id]: {usage}});
|
||||||
}
|
}
|
||||||
await server.dbManager.setDocGracePeriodStart(docIds[docIds.length - 1], new Date(2000, 1, 1));
|
await server.dbManager.setDocGracePeriodStart(docIds[docIds.length - 1], new Date(2000, 1, 1));
|
||||||
|
await server.dbManager.setDocGracePeriodStart(docIds[docIds.length - 2], new Date());
|
||||||
|
|
||||||
// Check that what's reported by /usage is accurate.
|
// Check that what's reported by /usage is accurate.
|
||||||
await assertOrgUsage(freeTeamOrgId, chimpy, {
|
await assertOrgUsage(freeTeamOrgId, chimpy, {
|
||||||
@ -2238,6 +2239,7 @@ describe('ApiServer', function() {
|
|||||||
dataSizeBytes: 999999999,
|
dataSizeBytes: 999999999,
|
||||||
attachmentsSizeBytes: 999999999,
|
attachmentsSizeBytes: 999999999,
|
||||||
}}});
|
}}});
|
||||||
|
await server.dbManager.setDocGracePeriodStart(docId, new Date());
|
||||||
|
|
||||||
// Check that /usage includes that document in the count.
|
// Check that /usage includes that document in the count.
|
||||||
await assertOrgUsage(freeTeamOrgId, chimpy, {
|
await assertOrgUsage(freeTeamOrgId, chimpy, {
|
||||||
|
Loading…
Reference in New Issue
Block a user