mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(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:
		
							parent
							
								
									01b1c310b5
								
							
						
					
					
						commit
						af5b3c9004
					
				
							
								
								
									
										171
									
								
								app/client/components/DocUsageBanner.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								app/client/components/DocUsageBanner.ts
									
									
									
									
									
										Normal 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;
 | 
				
			||||||
@ -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;
 | 
				
			||||||
 | 
					`);
 | 
				
			||||||
 | 
				
			|||||||
@ -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);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 | 
				
			|||||||
@ -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
 | 
				
			||||||
 | 
				
			|||||||
@ -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),
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -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};
 | 
				
			||||||
 | 
				
			|||||||
@ -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', `
 | 
				
			||||||
 | 
				
			|||||||
@ -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.
 | 
				
			||||||
 | 
				
			|||||||
@ -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
									
								
							
							
						
						
									
										6
									
								
								app/common/Usage.ts
									
									
									
									
									
										Normal 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;
 | 
				
			||||||
@ -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 {
 | 
				
			||||||
 | 
				
			|||||||
@ -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) {
 | 
				
			||||||
 | 
				
			|||||||
@ -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 = {
 | 
				
			||||||
 | 
				
			|||||||
@ -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.
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user