mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) Show usage banners in doc menu of free team sites
Summary: Also fixes a minor CSS regression in UserManager where the link to add a team member wasn't shown on a separate row. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3444
This commit is contained in:
		
							parent
							
								
									2f3cf59fc3
								
							
						
					
					
						commit
						74ec9358da
					
				
							
								
								
									
										165
									
								
								app/client/components/Banner.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								app/client/components/Banner.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,165 @@
 | 
			
		||||
import {colors, isNarrowScreenObs} from 'app/client/ui2018/cssVars';
 | 
			
		||||
import {icon} from 'app/client/ui2018/icons';
 | 
			
		||||
import {Disposable, dom, DomArg, DomElementArg, makeTestId, Observable, styled} from 'grainjs';
 | 
			
		||||
 | 
			
		||||
const testId = makeTestId('test-banner-');
 | 
			
		||||
 | 
			
		||||
export interface BannerOptions {
 | 
			
		||||
  /**
 | 
			
		||||
   * Content to display in the banner.
 | 
			
		||||
   */
 | 
			
		||||
  content: DomArg;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * The banner style.
 | 
			
		||||
   *
 | 
			
		||||
   * Warning banners have a yellow background. Error banners have a red
 | 
			
		||||
   * background.
 | 
			
		||||
   */
 | 
			
		||||
  style: 'warning' | 'error';
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Optional variant of `content` to display when screen width becomes narrow.
 | 
			
		||||
   */
 | 
			
		||||
  contentSmall?: DomArg;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Whether a button to close the banner should be shown.
 | 
			
		||||
   *
 | 
			
		||||
   * If true, `onClose` should also be specified; it will be called when the close
 | 
			
		||||
   * button is clicked.
 | 
			
		||||
   *
 | 
			
		||||
   * Defaults to false.
 | 
			
		||||
   */
 | 
			
		||||
  showCloseButton?: boolean;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Whether a button to collapse/expand the banner should be shown on narrow screens.
 | 
			
		||||
   *
 | 
			
		||||
   * Defaults to false.
 | 
			
		||||
   */
 | 
			
		||||
  showExpandButton?: boolean;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Function that is called when the banner close button is clicked.
 | 
			
		||||
   *
 | 
			
		||||
   * Should be used to handle disposal of the Banner.
 | 
			
		||||
   */
 | 
			
		||||
   onClose?(): void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A customizable banner for displaying at the top of a page.
 | 
			
		||||
 */
 | 
			
		||||
export class Banner extends Disposable {
 | 
			
		||||
  private readonly _isExpanded = Observable.create(this, true);
 | 
			
		||||
 | 
			
		||||
  constructor(private _options: BannerOptions) {
 | 
			
		||||
    super();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public buildDom() {
 | 
			
		||||
    return cssBanner(
 | 
			
		||||
      cssBanner.cls(`-${this._options.style}`),
 | 
			
		||||
      this._buildContent(),
 | 
			
		||||
      this._buildButtons(),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _buildContent() {
 | 
			
		||||
    const {content, contentSmall} = this._options;
 | 
			
		||||
    return dom.domComputed(use => {
 | 
			
		||||
      if (contentSmall === undefined) { return [content]; }
 | 
			
		||||
 | 
			
		||||
      const isExpanded = use(this._isExpanded);
 | 
			
		||||
      const isNarrowScreen = use(isNarrowScreenObs());
 | 
			
		||||
      return [isNarrowScreen && !isExpanded ? contentSmall : content];
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _buildButtons() {
 | 
			
		||||
    return cssButtons(
 | 
			
		||||
      this._options.showExpandButton ? this._buildExpandButton() : null,
 | 
			
		||||
      this._options.showCloseButton ? this._buildCloseButton() : null,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _buildCloseButton() {
 | 
			
		||||
    return cssButton('CrossBig',
 | 
			
		||||
      dom.on('click', () => this._options.onClose?.()),
 | 
			
		||||
      testId('close'),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _buildExpandButton() {
 | 
			
		||||
    return dom.maybe(isNarrowScreenObs(), () => {
 | 
			
		||||
      return cssExpandButton('Dropdown',
 | 
			
		||||
        cssExpandButton.cls('-expanded', this._isExpanded),
 | 
			
		||||
        dom.on('click', () => this._isExpanded.set(!this._isExpanded.get())),
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function buildBannerMessage(...domArgs: DomElementArg[]) {
 | 
			
		||||
  return cssBannerMessage(
 | 
			
		||||
    cssIcon('Idea'),
 | 
			
		||||
    cssLightlyBoldedText(domArgs),
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const cssBanner = styled('div', `
 | 
			
		||||
  display: flex;
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
  gap: 16px;
 | 
			
		||||
  color: white;
 | 
			
		||||
 | 
			
		||||
  &-warning {
 | 
			
		||||
    background: #E6A117;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &-error {
 | 
			
		||||
    background: ${colors.error};
 | 
			
		||||
  }
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const cssButtons = styled('div', `
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 16px;
 | 
			
		||||
  flex-shrink: 0;
 | 
			
		||||
  margin-left: auto;
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const cssButton = styled(icon, `
 | 
			
		||||
  width: 16px;
 | 
			
		||||
  height: 16px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  background-color: white;
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const cssExpandButton = styled(cssButton, `
 | 
			
		||||
  &-expanded {
 | 
			
		||||
    -webkit-mask-image: var(--icon-DropdownUp);
 | 
			
		||||
  }
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const cssLightlyBoldedText = styled('div', `
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
  background-color: white;
 | 
			
		||||
`);
 | 
			
		||||
@ -1,171 +0,0 @@
 | 
			
		||||
import {buildLimitStatusMessage, buildUpgradeMessage} from 'app/client/components/DocumentUsage';
 | 
			
		||||
import {sessionStorageBoolObs} from 'app/client/lib/localStorageObs';
 | 
			
		||||
import {DocPageModel} from 'app/client/models/DocPageModel';
 | 
			
		||||
import {colors, isNarrowScreenObs} from 'app/client/ui2018/cssVars';
 | 
			
		||||
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 _currentDocId = this._docPageModel.currentDocId;
 | 
			
		||||
  private readonly _currentDocUsage = this._docPageModel.currentDocUsage;
 | 
			
		||||
  private readonly _currentOrg = this._docPageModel.currentOrg;
 | 
			
		||||
 | 
			
		||||
  private readonly _dataLimitStatus = Computed.create(this, this._currentDocUsage, (_use, usage) => {
 | 
			
		||||
    return usage?.dataLimitStatus ?? 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(
 | 
			
		||||
              buildLimitStatusMessage('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 [
 | 
			
		||||
                  buildLimitStatusMessage(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;
 | 
			
		||||
@ -6,9 +6,10 @@ import {icon} from 'app/client/ui2018/icons';
 | 
			
		||||
import {cssLink} from 'app/client/ui2018/links';
 | 
			
		||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
 | 
			
		||||
import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/DocUsage';
 | 
			
		||||
import {Features} from 'app/common/Features';
 | 
			
		||||
import {Features, isFreeProduct} from 'app/common/Features';
 | 
			
		||||
import {commonUrls} from 'app/common/gristUrls';
 | 
			
		||||
import {capitalizeFirstWord} from 'app/common/gutil';
 | 
			
		||||
import {canUpgradeOrg} from 'app/common/roles';
 | 
			
		||||
import {Computed, Disposable, dom, DomContents, DomElementArg, makeTestId, styled} from 'grainjs';
 | 
			
		||||
 | 
			
		||||
const testId = makeTestId('test-doc-usage-');
 | 
			
		||||
@ -156,12 +157,15 @@ export class DocumentUsage extends Disposable {
 | 
			
		||||
      const status = use(this._dataLimitStatus);
 | 
			
		||||
      if (!org || !status) { return null; }
 | 
			
		||||
 | 
			
		||||
      const product = org.billingAccount?.product;
 | 
			
		||||
      return buildMessage([
 | 
			
		||||
        buildLimitStatusMessage(status, org.billingAccount?.product.features, {
 | 
			
		||||
        buildLimitStatusMessage(status, product?.features, {
 | 
			
		||||
          disableRawDataLink: true
 | 
			
		||||
        }),
 | 
			
		||||
        ' ',
 | 
			
		||||
        buildUpgradeMessage(org.access === 'owners')
 | 
			
		||||
        (product && isFreeProduct(product)
 | 
			
		||||
          ? [' ', buildUpgradeMessage(canUpgradeOrg(org))]
 | 
			
		||||
          : null
 | 
			
		||||
        ),
 | 
			
		||||
      ]);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
@ -226,8 +230,8 @@ export function buildLimitStatusMessage(
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function buildUpgradeMessage(isOwner: boolean, variant: 'short' | 'long' = 'long') {
 | 
			
		||||
  if (!isOwner) { return 'Contact the site owner to upgrade the plan to raise limits.'; }
 | 
			
		||||
export function buildUpgradeMessage(canUpgrade: boolean, variant: 'short' | 'long' = 'long') {
 | 
			
		||||
  if (!canUpgrade) { 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 [
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										93
									
								
								app/client/components/SiteUsageBanner.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								app/client/components/SiteUsageBanner.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,93 @@
 | 
			
		||||
import {Banner, buildBannerMessage} from 'app/client/components/Banner';
 | 
			
		||||
import {buildUpgradeMessage} from 'app/client/components/DocumentUsage';
 | 
			
		||||
import {sessionStorageBoolObs} from 'app/client/lib/localStorageObs';
 | 
			
		||||
import {HomeModel} from 'app/client/models/HomeModel';
 | 
			
		||||
import {isFreeProduct} from 'app/common/Features';
 | 
			
		||||
import {isOwner} from 'app/common/roles';
 | 
			
		||||
import {Disposable, dom, makeTestId, Observable} from 'grainjs';
 | 
			
		||||
 | 
			
		||||
const testId = makeTestId('test-site-usage-banner-');
 | 
			
		||||
 | 
			
		||||
export class SiteUsageBanner extends Disposable {
 | 
			
		||||
  private readonly _currentOrg = this._homeModel.app.currentOrg;
 | 
			
		||||
  private readonly _currentOrgUsage = this._homeModel.currentOrgUsage;
 | 
			
		||||
  private readonly _product = this._currentOrg?.billingAccount?.product;
 | 
			
		||||
  private readonly _currentUser = this._homeModel.app.currentValidUser;
 | 
			
		||||
 | 
			
		||||
  // Session storage observable. Set to false to dismiss the banner for the session.
 | 
			
		||||
  private _showApproachingLimitBannerPref?: Observable<boolean>;
 | 
			
		||||
 | 
			
		||||
  constructor(private _homeModel: HomeModel) {
 | 
			
		||||
    super();
 | 
			
		||||
 | 
			
		||||
    if (this._currentUser && isOwner(this._currentOrg)) {
 | 
			
		||||
      this._showApproachingLimitBannerPref = this.autoDispose(sessionStorageBoolObs(
 | 
			
		||||
        `u=${this._currentUser.id}:org=${this._currentOrg.id}:showApproachingLimitBanner`,
 | 
			
		||||
        true,
 | 
			
		||||
      ));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public buildDom() {
 | 
			
		||||
    return dom.maybe(this._currentOrgUsage, (usage) => {
 | 
			
		||||
      const {approachingLimit, gracePeriod, deleteOnly} = usage;
 | 
			
		||||
      if (deleteOnly > 0 || gracePeriod > 0) {
 | 
			
		||||
        return this._buildExceedingLimitsBanner(deleteOnly + gracePeriod);
 | 
			
		||||
      } else if (approachingLimit > 0) {
 | 
			
		||||
        return this._buildApproachingLimitsBanner(approachingLimit);
 | 
			
		||||
      } else {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _buildApproachingLimitsBanner(numDocs: number) {
 | 
			
		||||
    return dom.domComputed(use => {
 | 
			
		||||
      if (this._showApproachingLimitBannerPref && !use(this._showApproachingLimitBannerPref)) {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const limitsMessage = numDocs > 1
 | 
			
		||||
        ? `${numDocs} documents are approaching their limits.`
 | 
			
		||||
        : `${numDocs} document is approaching its limits.`;
 | 
			
		||||
      return dom.create(Banner, {
 | 
			
		||||
        content: buildBannerMessage(
 | 
			
		||||
          limitsMessage,
 | 
			
		||||
          (this._product && isFreeProduct(this._product)
 | 
			
		||||
            ? [' ', buildUpgradeMessage(true)]
 | 
			
		||||
            : null
 | 
			
		||||
          ),
 | 
			
		||||
          testId('text'),
 | 
			
		||||
        ),
 | 
			
		||||
        style: 'warning',
 | 
			
		||||
        showCloseButton: true,
 | 
			
		||||
        onClose: () => this._showApproachingLimitBannerPref?.set(false),
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _buildExceedingLimitsBanner(numDocs: number) {
 | 
			
		||||
    const limitsMessage = numDocs > 1
 | 
			
		||||
      ? `${numDocs} documents have exceeded their limits.`
 | 
			
		||||
      : `${numDocs} document has exceeded its limits.`;
 | 
			
		||||
    return dom.create(Banner, {
 | 
			
		||||
      content: buildBannerMessage(
 | 
			
		||||
        limitsMessage,
 | 
			
		||||
        (this._product && isFreeProduct(this._product)
 | 
			
		||||
          ? [' ', buildUpgradeMessage(true)]
 | 
			
		||||
          : null
 | 
			
		||||
        ),
 | 
			
		||||
        testId('text'),
 | 
			
		||||
      ),
 | 
			
		||||
      contentSmall: buildBannerMessage(
 | 
			
		||||
        (this._product && isFreeProduct(this._product)
 | 
			
		||||
          ? buildUpgradeMessage(true, 'short')
 | 
			
		||||
          : limitsMessage
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      style: 'error',
 | 
			
		||||
      showCloseButton: false,
 | 
			
		||||
      showExpandButton: true,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -20,7 +20,7 @@ import {OpenDocMode, UserOverride} from 'app/common/DocListAPI';
 | 
			
		||||
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
 | 
			
		||||
import {IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
 | 
			
		||||
import {getReconnectTimeout} from 'app/common/gutil';
 | 
			
		||||
import {canEdit} from 'app/common/roles';
 | 
			
		||||
import {canEdit, isOwner} from 'app/common/roles';
 | 
			
		||||
import {Document, NEW_DOCUMENT_CODE, Organization, UserAPI, Workspace} from 'app/common/UserAPI';
 | 
			
		||||
import {Holder, Observable, subscribe} from 'grainjs';
 | 
			
		||||
import {Computed, Disposable, dom, DomArg, DomElementArg} from 'grainjs';
 | 
			
		||||
@ -210,19 +210,19 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
 | 
			
		||||
 | 
			
		||||
  public offerRecovery(err: Error) {
 | 
			
		||||
    const isDenied = (err as any).code === 'ACL_DENY';
 | 
			
		||||
    const isOwner = this.currentDoc.get()?.access === 'owners';
 | 
			
		||||
    const isDocOwner = isOwner(this.currentDoc.get());
 | 
			
		||||
    confirmModal(
 | 
			
		||||
      "Error accessing document",
 | 
			
		||||
      "Reload",
 | 
			
		||||
      async () => window.location.reload(true),
 | 
			
		||||
      isOwner ? `You can try reloading the document, or using recovery mode. ` +
 | 
			
		||||
      isDocOwner ? `You can try reloading the document, or using recovery mode. ` +
 | 
			
		||||
        `Recovery mode opens the document to be fully accessible to owners, and ` +
 | 
			
		||||
        `inaccessible to others. It also disables formulas. ` +
 | 
			
		||||
        `[${err.message}]` :
 | 
			
		||||
        isDenied ? `Sorry, access to this document has been denied. [${err.message}]` :
 | 
			
		||||
        `Document owners can attempt to recover the document. [${err.message}]`,
 | 
			
		||||
      {  hideCancel: true,
 | 
			
		||||
         extraButtons: (isOwner && !isDenied) ? bigBasicButton('Enter recovery mode', dom.on('click', async () => {
 | 
			
		||||
         extraButtons: (isDocOwner && !isDenied) ? bigBasicButton('Enter recovery mode', dom.on('click', async () => {
 | 
			
		||||
           await this._api.getDocAPI(this.currentDocId.get()!).recover(true);
 | 
			
		||||
           window.location.reload(true);
 | 
			
		||||
         }), testId('modal-recovery-mode')) : null,
 | 
			
		||||
 | 
			
		||||
@ -7,6 +7,7 @@ import {AppModel, reportError} from 'app/client/models/AppModel';
 | 
			
		||||
import {reportMessage, UserError} from 'app/client/models/errors';
 | 
			
		||||
import {urlState} from 'app/client/models/gristUrlState';
 | 
			
		||||
import {ownerName} from 'app/client/models/WorkspaceInfo';
 | 
			
		||||
import {OrgUsageSummary} from 'app/common/DocUsage';
 | 
			
		||||
import {IHomePage} from 'app/common/gristUrls';
 | 
			
		||||
import {isLongerThan} from 'app/common/gutil';
 | 
			
		||||
import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs';
 | 
			
		||||
@ -75,6 +76,8 @@ export interface HomeModel {
 | 
			
		||||
  // user isn't allowed to create a doc.
 | 
			
		||||
  newDocWorkspace: Observable<Workspace|null|"unsaved">;
 | 
			
		||||
 | 
			
		||||
  currentOrgUsage: Observable<OrgUsageSummary|null>;
 | 
			
		||||
 | 
			
		||||
  createWorkspace(name: string): Promise<void>;
 | 
			
		||||
  renameWorkspace(id: number, name: string): Promise<void>;
 | 
			
		||||
  deleteWorkspace(id: number, forever: boolean): Promise<void>;
 | 
			
		||||
@ -155,6 +158,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
 | 
			
		||||
    wss.every((ws) => ws.isSupportWorkspace || ws.docs.length === 0) &&
 | 
			
		||||
    Boolean(use(this.newDocWorkspace))));
 | 
			
		||||
 | 
			
		||||
  public readonly currentOrgUsage: Observable<OrgUsageSummary|null> = Observable.create(this, null);
 | 
			
		||||
 | 
			
		||||
  private _userOrgPrefs = Observable.create<UserOrgPrefs|undefined>(this, this._app.currentOrg?.userOrgPrefs);
 | 
			
		||||
 | 
			
		||||
  constructor(private _app: AppModel, clientScope: ClientScope) {
 | 
			
		||||
@ -187,6 +192,8 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
 | 
			
		||||
      clientScope);
 | 
			
		||||
    const importSources = ImportSourceElement.fromArray(pluginManager.pluginsList);
 | 
			
		||||
    this.importSources.set(importSources);
 | 
			
		||||
 | 
			
		||||
    this._updateCurrentOrgUsage().catch(reportError);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Accessor for the AppModel containing this HomeModel.
 | 
			
		||||
@ -379,6 +386,14 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
 | 
			
		||||
      await this._app.api.updateOrg('current', {userOrgPrefs: org.userOrgPrefs});
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async _updateCurrentOrgUsage() {
 | 
			
		||||
    const currentOrg = this.app.currentOrg;
 | 
			
		||||
    if (!roles.isOwner(currentOrg)) { return; }
 | 
			
		||||
 | 
			
		||||
    const api = this.app.api;
 | 
			
		||||
    this.currentOrgUsage.set(await api.getOrgUsageSummary(currentOrg.id));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Check if active product allows just a single workspace.
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
import {DocUsageBanner} from 'app/client/components/DocUsageBanner';
 | 
			
		||||
import {SiteUsageBanner} from 'app/client/components/SiteUsageBanner';
 | 
			
		||||
import {domAsync} from 'app/client/lib/domAsync';
 | 
			
		||||
import {loadBillingPage} from 'app/client/lib/imports';
 | 
			
		||||
import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs';
 | 
			
		||||
@ -102,6 +103,7 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) {
 | 
			
		||||
    },
 | 
			
		||||
    headerMain: createTopBarHome(appModel),
 | 
			
		||||
    contentMain: createDocMenu(pageModel),
 | 
			
		||||
    contentTop: dom.create(SiteUsageBanner, pageModel),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -13,17 +13,18 @@ import {cssLink} from 'app/client/ui2018/links';
 | 
			
		||||
import {menuAnnotate} from 'app/client/ui2018/menus';
 | 
			
		||||
import {confirmModal} from 'app/client/ui2018/modals';
 | 
			
		||||
import {userOverrideParams} from 'app/common/gristUrls';
 | 
			
		||||
import {isOwner} from 'app/common/roles';
 | 
			
		||||
import {Disposable, dom, makeTestId, Observable, observable, styled} from 'grainjs';
 | 
			
		||||
 | 
			
		||||
const testId = makeTestId('test-tools-');
 | 
			
		||||
 | 
			
		||||
export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element {
 | 
			
		||||
  const docPageModel = gristDoc.docPageModel;
 | 
			
		||||
  const isOwner = docPageModel.currentDoc.get()?.access === 'owners';
 | 
			
		||||
  const isDocOwner = isOwner(docPageModel.currentDoc.get());
 | 
			
		||||
  const isOverridden = Boolean(docPageModel.userOverride.get());
 | 
			
		||||
  const canViewAccessRules = observable(false);
 | 
			
		||||
  function updateCanViewAccessRules() {
 | 
			
		||||
    canViewAccessRules.set((isOwner && !isOverridden) ||
 | 
			
		||||
    canViewAccessRules.set((isDocOwner && !isOverridden) ||
 | 
			
		||||
                           gristDoc.docModel.rules.getNumRows() > 0);
 | 
			
		||||
  }
 | 
			
		||||
  owner.autoDispose(gristDoc.docModel.rules.tableData.tableActionEmitter.addListener(updateCanViewAccessRules));
 | 
			
		||||
@ -103,7 +104,7 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
 | 
			
		||||
            testId('doctour'),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        !isOwner ? null : cssPageEntrySmall(
 | 
			
		||||
        !isDocOwner ? null : cssPageEntrySmall(
 | 
			
		||||
          cssPageLink(cssPageIcon('Remove'),
 | 
			
		||||
            dom.on('click', () => confirmModal('Delete document tour?', 'Delete', () =>
 | 
			
		||||
              gristDoc.docData.sendAction(['RemoveTable', 'GristDocTour']))
 | 
			
		||||
 | 
			
		||||
@ -283,13 +283,10 @@ export class UserManager extends Disposable {
 | 
			
		||||
            !member.name ? null : cssMemberSecondary(
 | 
			
		||||
              member.email, dom.cls('member-email'), testId('um-member-email')
 | 
			
		||||
            ),
 | 
			
		||||
            dom('span',
 | 
			
		||||
            (this._model.isPersonal
 | 
			
		||||
              ? this._buildSelfAnnotationDom(member)
 | 
			
		||||
              : this._buildAnnotationDom(member)
 | 
			
		||||
            ),
 | 
			
		||||
              testId('um-member-annotation'),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          member.isRemoved ? null : this._memberRoleSelector(member.effectiveAccess,
 | 
			
		||||
            member.inheritedAccess, this._model.isActiveUser(member)),
 | 
			
		||||
@ -364,15 +361,18 @@ export class UserManager extends Disposable {
 | 
			
		||||
      const annotation = annotations.users.get(user.email);
 | 
			
		||||
      if (!annotation) { return null; }
 | 
			
		||||
 | 
			
		||||
      let memberType: string;
 | 
			
		||||
      if (annotation.isSupport) {
 | 
			
		||||
        return cssMemberType('Grist support');
 | 
			
		||||
        memberType = 'Grist support';
 | 
			
		||||
      } else if (annotation.isMember && annotations.hasTeam) {
 | 
			
		||||
        return cssMemberType('Team member');
 | 
			
		||||
        memberType = 'Team member';
 | 
			
		||||
      } else if (annotations.hasTeam) {
 | 
			
		||||
        return cssMemberType('Outside collaborator');
 | 
			
		||||
        memberType = 'Outside collaborator';
 | 
			
		||||
      } else {
 | 
			
		||||
        return cssMemberType('Collaborator');
 | 
			
		||||
        memberType = 'Collaborator';
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return cssMemberType(memberType, testId('um-member-annotation'));
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,12 @@ export interface SnapshotWindow {
 | 
			
		||||
  unit: 'days' | 'month' | 'year';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Information about the product associated with an org or orgs.
 | 
			
		||||
export interface Product {
 | 
			
		||||
  name: string;
 | 
			
		||||
  features: Features;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// A product is essentially a list of flags and limits that we may enforce/support.
 | 
			
		||||
export interface Features {
 | 
			
		||||
  vanityDomain?: boolean;   // are user-selected domains allowed (unenforced) (default: true)
 | 
			
		||||
@ -60,3 +66,8 @@ export interface Features {
 | 
			
		||||
export function canAddOrgMembers(features: Features): boolean {
 | 
			
		||||
  return features.maxWorkspacesPerOrg !== 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Returns true if `product` is free.
 | 
			
		||||
export function isFreeProduct(product: Product): boolean {
 | 
			
		||||
  return ['starter', 'teamFree'].includes(product.name);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,7 @@ import {BrowserSettings} from 'app/common/BrowserSettings';
 | 
			
		||||
import {BulkColValues, TableColValues, TableRecordValue, TableRecordValues, UserAction} from 'app/common/DocActions';
 | 
			
		||||
import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
 | 
			
		||||
import {OrgUsageSummary} from 'app/common/DocUsage';
 | 
			
		||||
import {Features} from 'app/common/Features';
 | 
			
		||||
import {Product} from 'app/common/Features';
 | 
			
		||||
import {ICustomWidget} from 'app/common/CustomWidget';
 | 
			
		||||
import {isClient} from 'app/common/gristUrls';
 | 
			
		||||
import {FullUser} from 'app/common/LoginSessionAPI';
 | 
			
		||||
@ -73,12 +73,6 @@ export interface BillingAccount {
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Information about the product associated with an org or orgs.
 | 
			
		||||
export interface Product {
 | 
			
		||||
  name: string;
 | 
			
		||||
  features: Features;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// The upload types vary based on which fetch implementation is in use.  This is
 | 
			
		||||
// an incomplete list.  For example, node streaming types are supported by node-fetch.
 | 
			
		||||
export type UploadType = string | Blob | Buffer;
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,4 @@
 | 
			
		||||
import {isOwner} from 'app/common/roles';
 | 
			
		||||
import {ManagerDelta, PermissionDelta, UserAPI} from 'app/common/UserAPI';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@ -7,7 +8,7 @@ import {ManagerDelta, PermissionDelta, UserAPI} from 'app/common/UserAPI';
 | 
			
		||||
 */
 | 
			
		||||
export async function resetOrg(api: UserAPI, org: string|number) {
 | 
			
		||||
  const session = await api.getSessionActive();
 | 
			
		||||
  if (!(session.org && session.org.access === 'owners')) {
 | 
			
		||||
  if (!isOwner(session.org)) {
 | 
			
		||||
    throw new Error('user must be an owner of the org to be reset');
 | 
			
		||||
  }
 | 
			
		||||
  const billing = api.getBillingAPI();
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,5 @@
 | 
			
		||||
import {Organization} from 'app/common/UserAPI';
 | 
			
		||||
 | 
			
		||||
export const OWNER  = 'owners';
 | 
			
		||||
export const EDITOR = 'editors';
 | 
			
		||||
export const VIEWER = 'viewers';
 | 
			
		||||
@ -39,6 +41,15 @@ export function canView(role: string|null): boolean {
 | 
			
		||||
  return role !== null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isOwner(resource: {access: Role}|null): resource is {access: Role} {
 | 
			
		||||
  return resource?.access === OWNER;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function canUpgradeOrg(org: Organization|null): org is Organization {
 | 
			
		||||
  // TODO: Need to consider billing managers and support user.
 | 
			
		||||
  return isOwner(org);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Returns true if the role string is a valid role or null.
 | 
			
		||||
export function isValidRole(role: string|null): role is Role|null {
 | 
			
		||||
  return (roleOrder as Array<string|null>).includes(role);
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import {Features} from 'app/common/Features';
 | 
			
		||||
import {Features, Product as IProduct} from 'app/common/Features';
 | 
			
		||||
import {nativeValues} from 'app/gen-server/lib/values';
 | 
			
		||||
import * as assert from 'assert';
 | 
			
		||||
import {BillingAccount} from 'app/gen-server/entity/BillingAccount';
 | 
			
		||||
@ -67,14 +67,6 @@ export const suspendedFeatures: Features = {
 | 
			
		||||
  maxWorkspacesPerOrg: 0,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Basic fields needed for products supported by Grist.
 | 
			
		||||
 */
 | 
			
		||||
export interface IProduct {
 | 
			
		||||
  name: string;
 | 
			
		||||
  features: Features;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 *
 | 
			
		||||
 * Products are a bundle of enabled features.  Most products in
 | 
			
		||||
 | 
			
		||||
@ -17,10 +17,10 @@ export class HostedMetadataManager {
 | 
			
		||||
  private _lastPushTime: number = 0.0;
 | 
			
		||||
 | 
			
		||||
  // Callback for next opportunity to push changes.
 | 
			
		||||
  private _timeout: any = null;
 | 
			
		||||
  private _timeout: NodeJS.Timeout|null = null;
 | 
			
		||||
 | 
			
		||||
  // Maintains the update Promise to wait on it if the class is closing.
 | 
			
		||||
  private _push: Promise<any>|null;
 | 
			
		||||
  private _push: Promise<void>|null;
 | 
			
		||||
 | 
			
		||||
  // The default delay in milliseconds between metadata pushes to the database.
 | 
			
		||||
  private readonly _minPushDelayMs: number;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										10
									
								
								stubs/app/client/component/DocUsageBanner.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								stubs/app/client/component/DocUsageBanner.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
			
		||||
import {DocPageModel} from 'app/client/models/DocPageModel';
 | 
			
		||||
import {Disposable} from 'grainjs';
 | 
			
		||||
 | 
			
		||||
export class DocUsageBanner extends Disposable {
 | 
			
		||||
  constructor(_docPageModel: DocPageModel) { super(); }
 | 
			
		||||
 | 
			
		||||
  public buildDom() {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user