mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) Adds new view as banner
Summary: Diff removes view-as pill in the document breadcrumbs and add new view-as banner. Note: Banners are still missing mechanism to handle several banners. As of now both doc-usage and view-as banners could show up at the same time. Test Plan: Refactored existing test. Reviewers: jarek Reviewed By: jarek Subscribers: jarek Differential Revision: https://phab.getgrist.com/D3732
This commit is contained in:
		
							parent
							
								
									c0e9c18128
								
							
						
					
					
						commit
						cabac3d9d8
					
				@ -1,27 +1,21 @@
 | 
			
		||||
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
 | 
			
		||||
import {DocPageModel} from 'app/client/models/DocPageModel';
 | 
			
		||||
import {urlState} from 'app/client/models/gristUrlState';
 | 
			
		||||
import {createUserImage} from 'app/client/ui/UserImage';
 | 
			
		||||
import {cssMemberImage, cssMemberListItem, cssMemberPrimary,
 | 
			
		||||
        cssMemberSecondary, cssMemberText} from 'app/client/ui/UserItem';
 | 
			
		||||
import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons';
 | 
			
		||||
import {testId, theme} from 'app/client/ui2018/cssVars';
 | 
			
		||||
import {icon} from 'app/client/ui2018/icons';
 | 
			
		||||
import {menuCssClass, menuDivider} from 'app/client/ui2018/menus';
 | 
			
		||||
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
 | 
			
		||||
import {menuCssClass} from 'app/client/ui2018/menus';
 | 
			
		||||
import {PermissionDataWithExtraUsers} from 'app/common/ActiveDocAPI';
 | 
			
		||||
import {userOverrideParams} from 'app/common/gristUrls';
 | 
			
		||||
import {FullUser} from 'app/common/LoginSessionAPI';
 | 
			
		||||
import * as roles from 'app/common/roles';
 | 
			
		||||
import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL} from 'app/common/UserAPI';
 | 
			
		||||
import {getRealAccess, UserAccessData} from 'app/common/UserAPI';
 | 
			
		||||
import {Disposable, dom, Observable, styled} from 'grainjs';
 | 
			
		||||
import {cssMenu, cssMenuWrap, defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
 | 
			
		||||
import {cssMenu, cssMenuWrap, defaultMenuOptions, IPopupOptions, setPopupToCreateDom} from 'popweasel';
 | 
			
		||||
import {getUserRoleText} from 'app/common/UserAPI';
 | 
			
		||||
import {makeT} from 'app/client/lib/localization';
 | 
			
		||||
 | 
			
		||||
const roleNames: {[role: string]: string} = {
 | 
			
		||||
  [roles.OWNER]: 'Owner',
 | 
			
		||||
  [roles.EDITOR]: 'Editor',
 | 
			
		||||
  [roles.VIEWER]: 'Viewer',
 | 
			
		||||
};
 | 
			
		||||
const t = makeT('aclui.ViewAsDropdown');
 | 
			
		||||
 | 
			
		||||
function isSpecialEmail(email: string) {
 | 
			
		||||
  return email === ANONYMOUS_USER_EMAIL || email === EVERYONE_EMAIL;
 | 
			
		||||
@ -43,57 +37,53 @@ export class ACLUsersPopup extends Disposable {
 | 
			
		||||
        ...user,
 | 
			
		||||
        access: getRealAccess(user, permissionData),
 | 
			
		||||
      }))
 | 
			
		||||
      .filter(user => user.access && !isSpecialEmail(user.email));
 | 
			
		||||
        .filter(user => user.access && !isSpecialEmail(user.email))
 | 
			
		||||
        .filter(user => this._currentUser?.id !== user.id);
 | 
			
		||||
      this._attributeTableUsers = permissionData.attributeTableUsers;
 | 
			
		||||
      this._exampleUsers = permissionData.exampleUsers;
 | 
			
		||||
      this.isInitialized.set(true);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public attachPopup(elem: Element) {
 | 
			
		||||
  public attachPopup(elem: Element, options: IPopupOptions) {
 | 
			
		||||
    setPopupToCreateDom(elem, (ctl) => {
 | 
			
		||||
      const buildRow = (user: UserAccessData) => this._buildUserRow(user, this._currentUser, ctl);
 | 
			
		||||
      const buildRow =
 | 
			
		||||
        (user: UserAccessData) => this._buildUserRow(user);
 | 
			
		||||
      const buildExampleUserRow =
 | 
			
		||||
        (user: UserAccessData) => this._buildUserRow(user, {isExampleUser: true});
 | 
			
		||||
      return cssMenuWrap(cssMenu(
 | 
			
		||||
        dom.cls(menuCssClass),
 | 
			
		||||
        cssUsers.cls(''),
 | 
			
		||||
        cssHeader(t('ViewAs'), dom.show(this._shareUsers.length > 0)),
 | 
			
		||||
        dom.forEach(this._shareUsers, buildRow),
 | 
			
		||||
        // Add a divider between users-from-shares and users from attribute tables.
 | 
			
		||||
        (this._attributeTableUsers.length > 0) ? menuDivider() : null,
 | 
			
		||||
        dom.forEach(this._attributeTableUsers, buildRow),
 | 
			
		||||
        (this._attributeTableUsers.length > 0) ? cssHeader(t('UsersFrom')) : null,
 | 
			
		||||
        dom.forEach(this._attributeTableUsers, buildExampleUserRow),
 | 
			
		||||
        // Include example users only if there are not many "real" users.
 | 
			
		||||
        // It might be better to have an expandable section with these users, collapsed
 | 
			
		||||
        // by default, but that's beyond my UI ken.
 | 
			
		||||
        (this._shareUsers.length + this._attributeTableUsers.length < 5) ? [
 | 
			
		||||
          (this._exampleUsers.length > 0) ? menuDivider() : null,
 | 
			
		||||
          dom.forEach(this._exampleUsers, buildRow)
 | 
			
		||||
          (this._exampleUsers.length > 0) ? cssHeader(t('ExampleUsers')) : null,
 | 
			
		||||
          dom.forEach(this._exampleUsers, buildExampleUserRow)
 | 
			
		||||
        ] : null,
 | 
			
		||||
        (el) => { setTimeout(() => el.focus(), 0); },
 | 
			
		||||
        dom.onKeyDown({Escape: () => ctl.close()}),
 | 
			
		||||
      ));
 | 
			
		||||
    }, {...defaultMenuOptions, placement: 'bottom-end'});
 | 
			
		||||
    }, {...defaultMenuOptions, ...options});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _buildUserRow(user: UserAccessData, currentUser: FullUser|null, ctl: IOpenController) {
 | 
			
		||||
    const isCurrentUser = Boolean(currentUser && user.id === currentUser.id);
 | 
			
		||||
    return cssUserItem(
 | 
			
		||||
  private _buildUserRow(user: UserAccessData, opt: {isExampleUser?: boolean} = {}) {
 | 
			
		||||
    return dom('a',
 | 
			
		||||
      {class: cssMemberListItem.className + ' ' + cssUserItem.className},
 | 
			
		||||
      cssMemberImage(
 | 
			
		||||
        createUserImage(user, 'large')
 | 
			
		||||
        createUserImage(opt.isExampleUser ? 'exampleUser' : user, 'large')
 | 
			
		||||
      ),
 | 
			
		||||
      cssMemberText(
 | 
			
		||||
        cssMemberPrimary(user.name || dom('span', user.email),
 | 
			
		||||
          cssRole('(', roleNames[user.access!] || user.access || 'no access', ')', testId('acl-user-access')),
 | 
			
		||||
          cssRole('(', getUserRoleText(user), ')', testId('acl-user-access')),
 | 
			
		||||
        ),
 | 
			
		||||
        user.name ? cssMemberSecondary(user.email) : null
 | 
			
		||||
      ),
 | 
			
		||||
      basicButton(cssUserButton.cls(''), icon('Copy'), 'Copy Email',
 | 
			
		||||
        testId('acl-user-copy'),
 | 
			
		||||
        dom.on('click', async (ev, elem) => { await copyToClipboard(user.email); ctl.close(); }),
 | 
			
		||||
      ),
 | 
			
		||||
      basicButtonLink(cssUserButton.cls(''), cssUserButton.cls('-disabled', isCurrentUser),
 | 
			
		||||
        testId('acl-user-view-as'),
 | 
			
		||||
          icon('FieldLink'), 'View As',
 | 
			
		||||
          this._viewAs(user),
 | 
			
		||||
        ),
 | 
			
		||||
      this._viewAs(user),
 | 
			
		||||
      testId('acl-user-item'),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
@ -132,6 +122,9 @@ const cssUserItem = styled(cssMemberListItem, `
 | 
			
		||||
  &:hover {
 | 
			
		||||
    background-color: ${theme.lightHover};
 | 
			
		||||
  }
 | 
			
		||||
  &, &:hover, &:focus {
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
  }
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const cssRole = styled('span', `
 | 
			
		||||
@ -139,18 +132,10 @@ const cssRole = styled('span', `
 | 
			
		||||
  font-weight: normal;
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const cssUserButton = styled('div', `
 | 
			
		||||
  margin: 0 8px;
 | 
			
		||||
  border: none;
 | 
			
		||||
  display: inline-flex;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
  gap: 4px;
 | 
			
		||||
  &:hover {
 | 
			
		||||
    --icon-color: ${theme.controlFg};
 | 
			
		||||
    color: ${theme.controlFg};
 | 
			
		||||
    background-color: ${theme.hover};
 | 
			
		||||
  }
 | 
			
		||||
  &-disabled {
 | 
			
		||||
    visibility: hidden;
 | 
			
		||||
  }
 | 
			
		||||
const cssHeader = styled('div', `
 | 
			
		||||
  margin: 11px 24px 14px 24px;
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  text-transform: uppercase;
 | 
			
		||||
  font-size: ${vars.xsmallFontSize};
 | 
			
		||||
  color: ${theme.darkText};
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
@ -356,7 +356,8 @@ export class AccessRules extends Disposable {
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        bigBasicButton(t('AddUserAttributes'), dom.on('click', () => this._addUserAttributes())),
 | 
			
		||||
        bigBasicButton(t('Users'), cssDropdownIcon('Dropdown'), elem => this._aclUsersPopup.attachPopup(elem),
 | 
			
		||||
        bigBasicButton(t('ViewAs'), cssDropdownIcon('Dropdown'),
 | 
			
		||||
          elem => this._aclUsersPopup.attachPopup(elem, {placement: 'bottom-end'}),
 | 
			
		||||
          dom.style('visibility', use => use(this._aclUsersPopup.isInitialized) ? '' : 'hidden')),
 | 
			
		||||
      ),
 | 
			
		||||
      cssConditionError({style: 'margin-left: 16px'},
 | 
			
		||||
 | 
			
		||||
@ -16,7 +16,7 @@ export interface BannerOptions {
 | 
			
		||||
   * Warning banners have a yellow background. Error banners have a red
 | 
			
		||||
   * background.
 | 
			
		||||
   */
 | 
			
		||||
  style: 'warning' | 'error';
 | 
			
		||||
  style: 'warning' | 'error' | 'info';
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Optional variant of `content` to display when screen width becomes narrow.
 | 
			
		||||
@ -40,6 +40,11 @@ export interface BannerOptions {
 | 
			
		||||
   */
 | 
			
		||||
  showExpandButton?: boolean;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * If provided, applies the css class to the banner container.
 | 
			
		||||
   */
 | 
			
		||||
   bannerCssClass?: string;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Function that is called when the banner close button is clicked.
 | 
			
		||||
   *
 | 
			
		||||
@ -59,7 +64,7 @@ export class Banner extends Disposable {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public buildDom() {
 | 
			
		||||
    return cssBanner(
 | 
			
		||||
    return cssBanner({class: this._options.bannerCssClass || ''},
 | 
			
		||||
      cssBanner.cls(`-${this._options.style}`),
 | 
			
		||||
      this._buildContent(),
 | 
			
		||||
      this._buildButtons(),
 | 
			
		||||
@ -114,6 +119,11 @@ const cssBanner = styled('div', `
 | 
			
		||||
  gap: 16px;
 | 
			
		||||
  color: white;
 | 
			
		||||
 | 
			
		||||
  &-info {
 | 
			
		||||
    color: black;
 | 
			
		||||
    background: #FFFACD;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &-warning {
 | 
			
		||||
    background: #E6A117;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										132
									
								
								app/client/components/ViewAsBanner.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								app/client/components/ViewAsBanner.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,132 @@
 | 
			
		||||
import { reportError } from 'app/client/models/AppModel';
 | 
			
		||||
import { Banner } from "app/client/components/Banner";
 | 
			
		||||
import { DocPageModel } from "app/client/models/DocPageModel";
 | 
			
		||||
import { icon } from "app/client/ui2018/icons";
 | 
			
		||||
import { primaryButtonLink } from 'app/client/ui2018/buttons';
 | 
			
		||||
import { Disposable, dom, styled } from "grainjs";
 | 
			
		||||
import { testId, theme } from 'app/client/ui2018/cssVars';
 | 
			
		||||
import { urlState } from 'app/client/models/gristUrlState';
 | 
			
		||||
import { userOverrideParams } from 'app/common/gristUrls';
 | 
			
		||||
import { cssMenuItem } from 'popweasel';
 | 
			
		||||
import { getUserRoleText } from 'app/common/UserAPI';
 | 
			
		||||
import { PermissionDataWithExtraUsers } from 'app/common/ActiveDocAPI';
 | 
			
		||||
import { waitGrainObs } from 'app/common/gutil';
 | 
			
		||||
import { cssSelectBtn } from 'app/client/ui2018/select';
 | 
			
		||||
import { ACLUsersPopup } from 'app/client/aclui/ACLUsers';
 | 
			
		||||
import { UserOverride } from 'app/common/DocListAPI';
 | 
			
		||||
import { makeT } from 'app/client/lib/localization';
 | 
			
		||||
 | 
			
		||||
const t = makeT('components.ViewAsBanner');
 | 
			
		||||
 | 
			
		||||
export class ViewAsBanner extends Disposable {
 | 
			
		||||
 | 
			
		||||
  private _userOverride = this._docPageModel.userOverride;
 | 
			
		||||
  private _usersPopup = ACLUsersPopup.create(this);
 | 
			
		||||
 | 
			
		||||
  constructor (private _docPageModel: DocPageModel) {
 | 
			
		||||
    super();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public buildDom() {
 | 
			
		||||
    return dom.maybe(this._userOverride, (userOverride) => {
 | 
			
		||||
      this._initViewAsUsers().catch(reportError);
 | 
			
		||||
      return dom.create(Banner, {
 | 
			
		||||
        content: this._buildContent(userOverride),
 | 
			
		||||
        style: 'info',
 | 
			
		||||
        showCloseButton: false,
 | 
			
		||||
        showExpandButton: false,
 | 
			
		||||
        bannerCssClass: cssBanner.className,
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _buildContent(userOverride: UserOverride) {
 | 
			
		||||
    const {user, access} = userOverride;
 | 
			
		||||
    return cssContent(
 | 
			
		||||
      cssMessageText(
 | 
			
		||||
        cssMessageIcon('EyeShow'),
 | 
			
		||||
        'You are viewing this document as',
 | 
			
		||||
      ),
 | 
			
		||||
      cssSelectBtn(
 | 
			
		||||
        {tabIndex: '0'},
 | 
			
		||||
        cssBtnText(
 | 
			
		||||
          user ? cssMember(
 | 
			
		||||
            user.name || user.email,
 | 
			
		||||
            cssRole('(', getUserRoleText({...user, access}), ')', dom.show(Boolean(access))),
 | 
			
		||||
          ) : t('UnknownUser'),
 | 
			
		||||
        ),
 | 
			
		||||
        dom(
 | 
			
		||||
          'div', {style: 'flex: none;'},
 | 
			
		||||
          cssInlineCollapseIcon('Collapse'),
 | 
			
		||||
        ),
 | 
			
		||||
        elem => this._usersPopup.attachPopup(elem, {}),
 | 
			
		||||
        testId('select-open'),
 | 
			
		||||
      ),
 | 
			
		||||
      cssPrimaryButtonLink(
 | 
			
		||||
        'View as Yourself', cssIcon('Convert'),
 | 
			
		||||
        urlState().setHref(userOverrideParams(null)),
 | 
			
		||||
        testId('revert'),
 | 
			
		||||
      ),
 | 
			
		||||
      testId('view-as-banner'),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async _initViewAsUsers() {
 | 
			
		||||
    await waitGrainObs(this._docPageModel.gristDoc);
 | 
			
		||||
    const permissionData = await this._getUsersForViewAs();
 | 
			
		||||
    this._usersPopup.init(this._docPageModel, permissionData);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _getUsersForViewAs(): Promise<PermissionDataWithExtraUsers> {
 | 
			
		||||
    const docId = this._docPageModel.currentDocId.get()!;
 | 
			
		||||
    const docApi = this._docPageModel.appModel.api.getDocAPI(docId);
 | 
			
		||||
    return docApi.getUsersForViewAs();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const cssContent = styled('div', `
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  column-gap: 13px;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  & .${cssSelectBtn.className} {
 | 
			
		||||
    width: 184px;
 | 
			
		||||
  }
 | 
			
		||||
`);
 | 
			
		||||
const cssIcon = styled(icon, `
 | 
			
		||||
  margin-left: 10px;
 | 
			
		||||
`);
 | 
			
		||||
const cssMember = styled('span', `
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
  color: ${theme.text};
 | 
			
		||||
 | 
			
		||||
  .${cssMenuItem.className}-sel & {
 | 
			
		||||
    color: ${theme.menuItemSelectedFg};
 | 
			
		||||
  }
 | 
			
		||||
`);
 | 
			
		||||
const cssRole = styled('span', `
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
  margin-left: 1ch;
 | 
			
		||||
`);
 | 
			
		||||
const cssMessageText = styled('span', `
 | 
			
		||||
`);
 | 
			
		||||
const cssMessageIcon = styled(icon, `
 | 
			
		||||
  margin-right: 10px;
 | 
			
		||||
`);
 | 
			
		||||
const cssPrimaryButtonLink = styled(primaryButtonLink, `
 | 
			
		||||
  margin-left: 5px;
 | 
			
		||||
`);
 | 
			
		||||
const cssBtnText = styled('div', `
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  text-overflow: ellipsis;
 | 
			
		||||
`);
 | 
			
		||||
const cssInlineCollapseIcon = styled(icon, `
 | 
			
		||||
  margin: 0 2px;
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
`);
 | 
			
		||||
const cssBanner = styled('div', `
 | 
			
		||||
  border-bottom: 1px solid ${theme.pagePanelsBorder};
 | 
			
		||||
  height: 45px;
 | 
			
		||||
`);
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners';
 | 
			
		||||
import {ViewAsBanner} from 'app/client/components/ViewAsBanner';
 | 
			
		||||
import {domAsync} from 'app/client/lib/domAsync';
 | 
			
		||||
import {loadBillingPage} from 'app/client/lib/imports';
 | 
			
		||||
import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs';
 | 
			
		||||
@ -161,5 +162,6 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App)
 | 
			
		||||
    testId,
 | 
			
		||||
    contentTop: buildDocumentBanners(pageModel),
 | 
			
		||||
    contentBottom: dom.create(createBottomBarDoc, pageModel, leftPanelOpen, rightPanelOpen),
 | 
			
		||||
    banner: dom.create(ViewAsBanner, pageModel),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -9,7 +9,9 @@ import {transition, TransitionWatcher} from 'app/client/ui/transitions';
 | 
			
		||||
import {cssHideForNarrowScreen, isScreenResizing, mediaNotSmall, mediaSmall, theme} from 'app/client/ui2018/cssVars';
 | 
			
		||||
import {isNarrowScreenObs} from 'app/client/ui2018/cssVars';
 | 
			
		||||
import {icon} from 'app/client/ui2018/icons';
 | 
			
		||||
import {dom, DomElementArg, MultiHolder, noTestId, Observable, styled, subscribe, TestId} from "grainjs";
 | 
			
		||||
import {
 | 
			
		||||
  dom, DomElementArg, DomElementMethod, MultiHolder, noTestId, Observable, styled, subscribe, TestId
 | 
			
		||||
} from "grainjs";
 | 
			
		||||
import noop from 'lodash/noop';
 | 
			
		||||
import once from 'lodash/once';
 | 
			
		||||
import {SessionObs} from 'app/client/lib/sessionObs';
 | 
			
		||||
@ -36,6 +38,7 @@ export interface PageContents {
 | 
			
		||||
 | 
			
		||||
  headerMain: DomElementArg;
 | 
			
		||||
  contentMain: DomElementArg;
 | 
			
		||||
  banner?: DomElementArg;
 | 
			
		||||
 | 
			
		||||
  onResize?: () => void;          // Callback for when either pane is opened, closed, or resized.
 | 
			
		||||
  testId?: TestId;
 | 
			
		||||
@ -50,12 +53,15 @@ export function pagePanels(page: PageContents) {
 | 
			
		||||
  const onResize = page.onResize || (() => null);
 | 
			
		||||
  const leftOverlap = Observable.create(null, false);
 | 
			
		||||
  const dragResizer = Observable.create(null, false);
 | 
			
		||||
  const bannerHeight = Observable.create(null, 0);
 | 
			
		||||
  const isScreenResizingObs = isScreenResizing();
 | 
			
		||||
 | 
			
		||||
  let lastLeftOpen = left.panelOpen.get();
 | 
			
		||||
  let lastRightOpen = right?.panelOpen.get() || false;
 | 
			
		||||
  let leftPaneDom: HTMLElement;
 | 
			
		||||
  let rightPaneDom: HTMLElement;
 | 
			
		||||
  let mainHeaderDom: HTMLElement;
 | 
			
		||||
  let contentTopDom: HTMLElement;
 | 
			
		||||
  let onLeftTransitionFinish = noop;
 | 
			
		||||
 | 
			
		||||
  // When switching to mobile mode, close panels; when switching to desktop, restore the
 | 
			
		||||
@ -107,13 +113,30 @@ export function pagePanels(page: PageContents) {
 | 
			
		||||
    dom.autoDispose(sub2),
 | 
			
		||||
    dom.autoDispose(commandsGroup),
 | 
			
		||||
    dom.autoDispose(leftOverlap),
 | 
			
		||||
    page.contentTop,
 | 
			
		||||
    dom('div', page.contentTop, elem => { contentTopDom = elem; }),
 | 
			
		||||
    dom.maybe(page.banner, () => {
 | 
			
		||||
      let elem: HTMLElement;
 | 
			
		||||
      const updateTop = () => {
 | 
			
		||||
        const height = mainHeaderDom.getBoundingClientRect().bottom;
 | 
			
		||||
        elem.style.top = height + 'px';
 | 
			
		||||
      };
 | 
			
		||||
      setTimeout(() => watchHeightElem(contentTopDom, updateTop));
 | 
			
		||||
      const lis = isScreenResizingObs.addListener(val => val || updateTop());
 | 
			
		||||
      return elem = cssBannerContainer(
 | 
			
		||||
        page.banner,
 | 
			
		||||
        watchHeight(h => bannerHeight.set(h)),
 | 
			
		||||
        dom.autoDispose(lis),
 | 
			
		||||
      );
 | 
			
		||||
    }),
 | 
			
		||||
    cssContentMain(
 | 
			
		||||
      leftPaneDom = cssLeftPane(
 | 
			
		||||
        testId('left-panel'),
 | 
			
		||||
        cssOverflowContainer(
 | 
			
		||||
          contentWrapper = cssLeftPanelContainer(
 | 
			
		||||
            cssLeftPaneHeader(left.header),
 | 
			
		||||
            cssLeftPaneHeader(
 | 
			
		||||
              left.header,
 | 
			
		||||
              dom.style('margin-bottom', use => use(bannerHeight) + 'px')
 | 
			
		||||
            ),
 | 
			
		||||
            left.content,
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
@ -242,7 +265,7 @@ export function pagePanels(page: PageContents) {
 | 
			
		||||
        cssHideForNarrowScreen.cls('')),
 | 
			
		||||
 | 
			
		||||
      cssMainPane(
 | 
			
		||||
        cssTopHeader(
 | 
			
		||||
        mainHeaderDom = cssTopHeader(
 | 
			
		||||
          testId('top-header'),
 | 
			
		||||
          (left.hideOpener ? null :
 | 
			
		||||
            cssPanelOpener('PanelRight', cssPanelOpener.cls('-open', left.panelOpen),
 | 
			
		||||
@ -260,6 +283,7 @@ export function pagePanels(page: PageContents) {
 | 
			
		||||
              dom.on('click', () => toggleObs(right.panelOpen)),
 | 
			
		||||
              cssHideForNarrowScreen.cls(''))
 | 
			
		||||
          ),
 | 
			
		||||
          dom.style('margin-bottom', use => use(bannerHeight) + 'px'),
 | 
			
		||||
        ),
 | 
			
		||||
        page.contentMain,
 | 
			
		||||
        cssMainPane.cls('-left-overlap', leftOverlap),
 | 
			
		||||
@ -275,7 +299,10 @@ export function pagePanels(page: PageContents) {
 | 
			
		||||
 | 
			
		||||
        rightPaneDom = cssRightPane(
 | 
			
		||||
          testId('right-panel'),
 | 
			
		||||
          cssRightPaneHeader(right.header),
 | 
			
		||||
          cssRightPaneHeader(
 | 
			
		||||
            right.header,
 | 
			
		||||
            dom.style('margin-bottom', use => use(bannerHeight) + 'px')
 | 
			
		||||
          ),
 | 
			
		||||
          right.content,
 | 
			
		||||
 | 
			
		||||
          dom.style('width', (use) => use(right.panelOpen) ? use(right.panelWidth) + 'px' : ''),
 | 
			
		||||
@ -606,7 +633,11 @@ const cssHiddenInput = styled('input', `
 | 
			
		||||
  font-size: 1;
 | 
			
		||||
  z-index: -1;
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const cssBannerContainer = styled('div', `
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  z-index: 11;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
`);
 | 
			
		||||
// watchElementForBlur does not work if focus is on body. Which never happens when running in Grist
 | 
			
		||||
// because focus is constantly given to the copypasteField. But it does happen when running inside a
 | 
			
		||||
// projects test. For that latter case we had a hidden <input> field to the dom and give it focus.
 | 
			
		||||
@ -617,3 +648,15 @@ function maybePatchDomAndChangeFocus() {
 | 
			
		||||
    hiddenInput.focus();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
// Watch for changes in dom subtree and call callback with element height;
 | 
			
		||||
function watchHeight(callback: (height: number) => void): DomElementMethod {
 | 
			
		||||
  return elem => watchHeightElem(elem, callback);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function watchHeightElem(elem: HTMLElement, callback: (height: number) => void) {
 | 
			
		||||
  const onChange = () => callback(elem.getBoundingClientRect().height);
 | 
			
		||||
  const observer = new MutationObserver(onChange);
 | 
			
		||||
  observer.observe(elem, {childList: true, subtree: true, attributes: true});
 | 
			
		||||
  dom.onDisposeElem(elem, () => observer.disconnect());
 | 
			
		||||
  onChange();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -7,13 +7,10 @@ import {buildExamples} from 'app/client/ui/ExampleInfo';
 | 
			
		||||
import {createHelpTools, cssLinkText, cssPageEntry, cssPageEntryMain, cssPageEntrySmall,
 | 
			
		||||
        cssPageIcon, cssPageLink, cssSectionHeader, cssSpacer, cssSplitPageEntry,
 | 
			
		||||
        cssTools} from 'app/client/ui/LeftPanelCommon';
 | 
			
		||||
import {hoverTooltip, tooltipCloseButton} from 'app/client/ui/tooltips';
 | 
			
		||||
import {theme} from 'app/client/ui2018/cssVars';
 | 
			
		||||
import {icon} from 'app/client/ui2018/icons';
 | 
			
		||||
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';
 | 
			
		||||
 | 
			
		||||
@ -44,7 +41,6 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
 | 
			
		||||
            menuAnnotate('Beta', cssBetaTag.cls(''))
 | 
			
		||||
          ),
 | 
			
		||||
          _canViewAccessRules ? urlState().setLinkUrl({docPage: 'acl'}) : null,
 | 
			
		||||
          isOverridden ? addRevertViewAsUI() : null,
 | 
			
		||||
        );
 | 
			
		||||
      }),
 | 
			
		||||
      testId('access-rules'),
 | 
			
		||||
@ -178,49 +174,6 @@ export interface AutomaticHelpToolInfo {
 | 
			
		||||
  markAsSeen: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// When viewing a page as another user, the "Access Rules" page link includes a button to revert
 | 
			
		||||
// the user and open the page, and a click on the page link shows a tooltip to revert.
 | 
			
		||||
function addRevertViewAsUI() {
 | 
			
		||||
  return [
 | 
			
		||||
    // A button that allows reverting back to yourself.
 | 
			
		||||
    dom('a',
 | 
			
		||||
      cssExampleCardOpener.cls(''),
 | 
			
		||||
      cssRevertViewAsButton.cls(''),
 | 
			
		||||
      icon('Convert'),
 | 
			
		||||
      urlState().setHref(userOverrideParams(null, {docPage: 'acl'})),
 | 
			
		||||
      dom.on('click', (ev) => ev.stopPropagation()),    // Avoid refreshing the tooltip.
 | 
			
		||||
      testId('revert-view-as'),
 | 
			
		||||
    ),
 | 
			
		||||
 | 
			
		||||
    // A tooltip that allows reverting back to yourself.
 | 
			
		||||
    hoverTooltip((ctl) =>
 | 
			
		||||
      cssConvertTooltip(icon('Convert'),
 | 
			
		||||
        cssLink(t('ViewingAsYourself'),
 | 
			
		||||
          urlState().setHref(userOverrideParams(null, {docPage: 'acl'})),
 | 
			
		||||
        ),
 | 
			
		||||
        tooltipCloseButton(ctl),
 | 
			
		||||
      ),
 | 
			
		||||
      {
 | 
			
		||||
        openOnClick: true,
 | 
			
		||||
        closeOnClick: false,
 | 
			
		||||
        openDelay: 100,
 | 
			
		||||
        closeDelay: 400,
 | 
			
		||||
        placement: 'top',
 | 
			
		||||
      }
 | 
			
		||||
    ),
 | 
			
		||||
  ];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const cssConvertTooltip = styled('div', `
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  --icon-color: ${theme.controlFg};
 | 
			
		||||
 | 
			
		||||
  & > .${cssLink.className} {
 | 
			
		||||
    margin-left: 8px;
 | 
			
		||||
  }
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const cssExampleCardOpener = styled('div', `
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  margin-right: 4px;
 | 
			
		||||
@ -241,13 +194,6 @@ const cssExampleCardOpener = styled('div', `
 | 
			
		||||
  }
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const cssRevertViewAsButton = styled(cssExampleCardOpener, `
 | 
			
		||||
  background-color: ${theme.iconButtonSecondaryBg};
 | 
			
		||||
  &:hover {
 | 
			
		||||
    background-color: ${theme.iconButtonSecondaryHoverBg};
 | 
			
		||||
  }
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const cssBetaTag = styled('div', `
 | 
			
		||||
  .${cssPageEntry.className}-disabled & {
 | 
			
		||||
    opacity: 0.4;
 | 
			
		||||
 | 
			
		||||
@ -82,7 +82,6 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
 | 
			
		||||
          isFork: pageModel.isFork,
 | 
			
		||||
          isBareFork: pageModel.isBareFork,
 | 
			
		||||
          isRecoveryMode: pageModel.isRecoveryMode,
 | 
			
		||||
          userOverride: pageModel.userOverride,
 | 
			
		||||
          isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork)),
 | 
			
		||||
          isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)),
 | 
			
		||||
          isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)),
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
import {colors, theme} from 'app/client/ui2018/cssVars';
 | 
			
		||||
import {FullUser} from 'app/common/LoginSessionAPI';
 | 
			
		||||
import {dom, DomElementArg, styled} from 'grainjs';
 | 
			
		||||
import {icon} from 'app/client/ui2018/icons';
 | 
			
		||||
 | 
			
		||||
export type Size = 'small' | 'medium' | 'large';
 | 
			
		||||
 | 
			
		||||
@ -8,10 +9,11 @@ export type Size = 'small' | 'medium' | 'large';
 | 
			
		||||
 * Returns a DOM element showing a circular icon with a user's picture, or the user's initials if
 | 
			
		||||
 * picture is missing. Also varies the color of the circle when using initials.
 | 
			
		||||
 */
 | 
			
		||||
export function createUserImage(user: FullUser|null, size: Size, ...args: DomElementArg[]): HTMLElement {
 | 
			
		||||
export function createUserImage(user: FullUser|'exampleUser'|null, size: Size, ...args: DomElementArg[]): HTMLElement {
 | 
			
		||||
  let initials: string;
 | 
			
		||||
  return cssUserImage(
 | 
			
		||||
    cssUserImage.cls('-' + size),
 | 
			
		||||
    (user === 'exampleUser') ? [cssUserImage.cls('-example'), cssExampleUserIcon('EyeShow')] :
 | 
			
		||||
    (!user || user.anonymous) ? cssUserImage.cls('-anon') :
 | 
			
		||||
    [
 | 
			
		||||
      (user.picture ? cssUserPicture({src: user.picture}, dom.on('error', (ev, el) => dom.hideElem(el, true))) : null),
 | 
			
		||||
@ -113,6 +115,11 @@ export const cssUserImage = styled('div', `
 | 
			
		||||
  &-reduced {
 | 
			
		||||
    font-size: var(--reduced-font-size);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &-example {
 | 
			
		||||
    background-color: ${colors.slate};
 | 
			
		||||
    border: 1px solid ${colors.slate};
 | 
			
		||||
  }
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const cssUserPicture = styled('img', `
 | 
			
		||||
@ -124,3 +131,10 @@ const cssUserPicture = styled('img', `
 | 
			
		||||
  border-radius: 100px;
 | 
			
		||||
  box-sizing: content-box;    /* keep the border outside of the size of the image */
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
const cssExampleUserIcon = styled(icon, `
 | 
			
		||||
  background-color: white;
 | 
			
		||||
  width: 45px;
 | 
			
		||||
  height: 45px;
 | 
			
		||||
  transform: scaleY(0.75);
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
@ -11,8 +11,6 @@ import { cssHideForNarrowScreen, mediaNotSmall, testId, theme } from 'app/client
 | 
			
		||||
import { editableLabel } from 'app/client/ui2018/editableLabel';
 | 
			
		||||
import { icon } from 'app/client/ui2018/icons';
 | 
			
		||||
import { cssLink } from 'app/client/ui2018/links';
 | 
			
		||||
import { UserOverride } from 'app/common/DocListAPI';
 | 
			
		||||
import { userOverrideParams } from 'app/common/gristUrls';
 | 
			
		||||
import { BindableValue, dom, Observable, styled } from 'grainjs';
 | 
			
		||||
import { tooltip } from 'popweasel';
 | 
			
		||||
 | 
			
		||||
@ -99,7 +97,6 @@ export function docBreadcrumbs(
 | 
			
		||||
    isBareFork: Observable<boolean>,
 | 
			
		||||
    isFiddle: Observable<boolean>,
 | 
			
		||||
    isRecoveryMode: Observable<boolean>,
 | 
			
		||||
    userOverride: Observable<UserOverride|null>,
 | 
			
		||||
    isSnapshot?: Observable<boolean>,
 | 
			
		||||
    isPublic?: Observable<boolean>,
 | 
			
		||||
  }
 | 
			
		||||
@ -152,16 +149,6 @@ export function docBreadcrumbs(
 | 
			
		||||
                                 icon('CrossSmall')),
 | 
			
		||||
                             testId('recovery-mode-tag'));
 | 
			
		||||
        }
 | 
			
		||||
        const userOverride = use(options.userOverride);
 | 
			
		||||
        if (userOverride) {
 | 
			
		||||
          return cssAlertTag(userOverride.user?.email || t('Override'),
 | 
			
		||||
            dom('a',
 | 
			
		||||
              urlState().setHref(userOverrideParams(null)),
 | 
			
		||||
              icon('CrossSmall')
 | 
			
		||||
            ),
 | 
			
		||||
            testId('user-override-tag')
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
        if (use(options.isFiddle)) {
 | 
			
		||||
          return cssTag(t('Fiddle'), tooltip({title: t('FiddleExplanation')}), testId('fiddle-tag'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import {ActionSummary} from 'app/common/ActionSummary';
 | 
			
		||||
import {ApplyUAResult, QueryFilters} from 'app/common/ActiveDocAPI';
 | 
			
		||||
import {ApplyUAResult, PermissionDataWithExtraUsers, QueryFilters} from 'app/common/ActiveDocAPI';
 | 
			
		||||
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
 | 
			
		||||
import {BillingAPI, BillingAPIImpl} from 'app/common/BillingAPI';
 | 
			
		||||
import {BrowserSettings} from 'app/common/BrowserSettings';
 | 
			
		||||
@ -199,6 +199,16 @@ export function getRealAccess(user: UserAccessData, permissionData: PermissionDa
 | 
			
		||||
  return roles.getStrongestRole(user.access, inheritedAccess);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const roleNames: {[role: string]: string} = {
 | 
			
		||||
  [roles.OWNER]: 'Owner',
 | 
			
		||||
  [roles.EDITOR]: 'Editor',
 | 
			
		||||
  [roles.VIEWER]: 'Viewer',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function getUserRoleText(user: UserAccessData) {
 | 
			
		||||
  return roleNames[user.access!] || user.access || 'no access';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ActiveSessionInfo {
 | 
			
		||||
  user: FullUser & {helpScoutSignature?: string};
 | 
			
		||||
  org: Organization|null;
 | 
			
		||||
@ -401,6 +411,9 @@ export interface DocAPI {
 | 
			
		||||
  // Upload a single attachment and return the resulting metadata row ID.
 | 
			
		||||
  // The arguments are passed to FormData.append.
 | 
			
		||||
  uploadAttachment(value: string | Blob, filename?: string): Promise<number>;
 | 
			
		||||
 | 
			
		||||
  // Get users that are worth proposing to "View As" for access control purposes.
 | 
			
		||||
  getUsersForViewAs(): Promise<PermissionDataWithExtraUsers>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Operations that are supported by a doc worker.
 | 
			
		||||
@ -833,6 +846,10 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getUsersForViewAs(): Promise<PermissionDataWithExtraUsers> {
 | 
			
		||||
    return this.requestJson(`${this._url}/usersForViewAs`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async forceReload(): Promise<void> {
 | 
			
		||||
    await this.request(`${this._url}/force-reload`, {
 | 
			
		||||
      method: 'POST'
 | 
			
		||||
 | 
			
		||||
@ -50,6 +50,7 @@ export class DocApiForwarder {
 | 
			
		||||
    app.use('/api/docs/:docId/apply', withDoc);
 | 
			
		||||
    app.use('/api/docs/:docId/attachments', withDoc);
 | 
			
		||||
    app.use('/api/docs/:docId/snapshots', withDoc);
 | 
			
		||||
    app.use('/api/docs/:docId/usersForViewAs', withDoc);
 | 
			
		||||
    app.use('/api/docs/:docId/replace', withDoc);
 | 
			
		||||
    app.use('/api/docs/:docId/flush', withDoc);
 | 
			
		||||
    app.use('/api/docs/:docId/states', withDoc);
 | 
			
		||||
 | 
			
		||||
@ -1504,7 +1504,7 @@ export class ActiveDoc extends EventEmitter {
 | 
			
		||||
   *
 | 
			
		||||
   * Example users are always included.
 | 
			
		||||
   */
 | 
			
		||||
  public async getUsersForViewAs(docSession: DocSession): Promise<PermissionDataWithExtraUsers> {
 | 
			
		||||
  public async getUsersForViewAs(docSession: OptDocSession): Promise<PermissionDataWithExtraUsers> {
 | 
			
		||||
    // Make sure we have rights to view access rules.
 | 
			
		||||
    const db = this.getHomeDbManager();
 | 
			
		||||
    if (!db || !await this._granularAccess.hasAccessRulesPermission(docSession)) {
 | 
			
		||||
 | 
			
		||||
@ -673,6 +673,11 @@ export class DocWorkerApi {
 | 
			
		||||
      res.json({snapshots});
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    this._app.get('/api/docs/:docId/usersForViewAs', isOwner, withDoc(async (activeDoc, req, res) => {
 | 
			
		||||
      const docSession = docSessionFromRequest(req);
 | 
			
		||||
      res.json(await activeDoc.getUsersForViewAs(docSession));
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    this._app.post('/api/docs/:docId/snapshots/remove', isOwner, withDoc(async (activeDoc, req, res) => {
 | 
			
		||||
      const docSession = docSessionFromRequest(req);
 | 
			
		||||
      const snapshotIds = req.body.snapshotIds as string[];
 | 
			
		||||
 | 
			
		||||
@ -567,7 +567,6 @@
 | 
			
		||||
      "Reset": "Reset",
 | 
			
		||||
      "AddTableRules": "Add Table Rules",
 | 
			
		||||
      "AddUserAttributes": "Add User Attributes",
 | 
			
		||||
      "Users": "Users",
 | 
			
		||||
      "UserAttributes": "User Attributes",
 | 
			
		||||
      "AttributeToLookUp": "Attribute to Look Up",
 | 
			
		||||
      "LookupTable": "Lookup Table",
 | 
			
		||||
@ -591,7 +590,13 @@
 | 
			
		||||
      "RemoveRulesMentioningTable": "Remove {{- tableId }} rules",
 | 
			
		||||
      "RemoveRulesMentioningColumn": "Remove column {{- colId }} from {{- tableId }} rules",
 | 
			
		||||
      "RemoveUserAttribute": "Remove {{- name }} user attribute",
 | 
			
		||||
      "MemoEditorPlaceholder": "Type a message..."
 | 
			
		||||
      "MemoEditorPlaceholder": "Type a message...",
 | 
			
		||||
      "ViewAs": "View As"
 | 
			
		||||
    },
 | 
			
		||||
    "ViewAsDropdown": {
 | 
			
		||||
      "ViewAs": "View As",
 | 
			
		||||
      "UsersFrom": "Users from table",
 | 
			
		||||
      "ExampleUsers": "Example Users"
 | 
			
		||||
    },
 | 
			
		||||
    "PermissionsWidget": {
 | 
			
		||||
      "AllowAll": "Allow All",
 | 
			
		||||
@ -729,7 +734,7 @@
 | 
			
		||||
      "PluginColon": "Plugin: ",
 | 
			
		||||
      "SectionColon": "Section: "
 | 
			
		||||
    },
 | 
			
		||||
    "Drafts": { 
 | 
			
		||||
    "Drafts": {
 | 
			
		||||
      "UndoDiscard":"Undo discard",
 | 
			
		||||
      "RestoreLastEdit":"Restore last edit"
 | 
			
		||||
    },
 | 
			
		||||
@ -777,6 +782,9 @@
 | 
			
		||||
    "ValidationPanel": {
 | 
			
		||||
      "RuleLength":"Rule {{length}}",
 | 
			
		||||
      "UpdateFormula":"Update formula (Shift+Enter)"
 | 
			
		||||
    },
 | 
			
		||||
    "ViewAsBanner": {
 | 
			
		||||
      "UnknownUser": "Unknown User"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user