(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:
Cyprien P 2023-01-03 11:52:25 +01:00
parent c0e9c18128
commit cabac3d9d8
15 changed files with 282 additions and 132 deletions

View File

@ -1,27 +1,21 @@
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
import {DocPageModel} from 'app/client/models/DocPageModel'; import {DocPageModel} from 'app/client/models/DocPageModel';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';
import {createUserImage} from 'app/client/ui/UserImage'; import {createUserImage} from 'app/client/ui/UserImage';
import {cssMemberImage, cssMemberListItem, cssMemberPrimary, import {cssMemberImage, cssMemberListItem, cssMemberPrimary,
cssMemberSecondary, cssMemberText} from 'app/client/ui/UserItem'; cssMemberSecondary, cssMemberText} from 'app/client/ui/UserItem';
import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons'; import {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {testId, theme} from 'app/client/ui2018/cssVars'; import {menuCssClass} from 'app/client/ui2018/menus';
import {icon} from 'app/client/ui2018/icons';
import {menuCssClass, menuDivider} from 'app/client/ui2018/menus';
import {PermissionDataWithExtraUsers} from 'app/common/ActiveDocAPI'; import {PermissionDataWithExtraUsers} from 'app/common/ActiveDocAPI';
import {userOverrideParams} from 'app/common/gristUrls'; import {userOverrideParams} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI'; import {FullUser} from 'app/common/LoginSessionAPI';
import * as roles from 'app/common/roles';
import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL} from 'app/common/UserAPI'; import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL} from 'app/common/UserAPI';
import {getRealAccess, UserAccessData} from 'app/common/UserAPI'; import {getRealAccess, UserAccessData} from 'app/common/UserAPI';
import {Disposable, dom, Observable, styled} from 'grainjs'; 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} = { const t = makeT('aclui.ViewAsDropdown');
[roles.OWNER]: 'Owner',
[roles.EDITOR]: 'Editor',
[roles.VIEWER]: 'Viewer',
};
function isSpecialEmail(email: string) { function isSpecialEmail(email: string) {
return email === ANONYMOUS_USER_EMAIL || email === EVERYONE_EMAIL; return email === ANONYMOUS_USER_EMAIL || email === EVERYONE_EMAIL;
@ -43,57 +37,53 @@ export class ACLUsersPopup extends Disposable {
...user, ...user,
access: getRealAccess(user, permissionData), 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._attributeTableUsers = permissionData.attributeTableUsers;
this._exampleUsers = permissionData.exampleUsers; this._exampleUsers = permissionData.exampleUsers;
this.isInitialized.set(true); this.isInitialized.set(true);
} }
} }
public attachPopup(elem: Element) { public attachPopup(elem: Element, options: IPopupOptions) {
setPopupToCreateDom(elem, (ctl) => { 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( return cssMenuWrap(cssMenu(
dom.cls(menuCssClass), dom.cls(menuCssClass),
cssUsers.cls(''), cssUsers.cls(''),
cssHeader(t('ViewAs'), dom.show(this._shareUsers.length > 0)),
dom.forEach(this._shareUsers, buildRow), dom.forEach(this._shareUsers, buildRow),
// Add a divider between users-from-shares and users from attribute tables. (this._attributeTableUsers.length > 0) ? cssHeader(t('UsersFrom')) : null,
(this._attributeTableUsers.length > 0) ? menuDivider() : null, dom.forEach(this._attributeTableUsers, buildExampleUserRow),
dom.forEach(this._attributeTableUsers, buildRow),
// Include example users only if there are not many "real" users. // Include example users only if there are not many "real" users.
// It might be better to have an expandable section with these users, collapsed // It might be better to have an expandable section with these users, collapsed
// by default, but that's beyond my UI ken. // by default, but that's beyond my UI ken.
(this._shareUsers.length + this._attributeTableUsers.length < 5) ? [ (this._shareUsers.length + this._attributeTableUsers.length < 5) ? [
(this._exampleUsers.length > 0) ? menuDivider() : null, (this._exampleUsers.length > 0) ? cssHeader(t('ExampleUsers')) : null,
dom.forEach(this._exampleUsers, buildRow) dom.forEach(this._exampleUsers, buildExampleUserRow)
] : null, ] : null,
(el) => { setTimeout(() => el.focus(), 0); }, (el) => { setTimeout(() => el.focus(), 0); },
dom.onKeyDown({Escape: () => ctl.close()}), dom.onKeyDown({Escape: () => ctl.close()}),
)); ));
}, {...defaultMenuOptions, placement: 'bottom-end'}); }, {...defaultMenuOptions, ...options});
} }
private _buildUserRow(user: UserAccessData, currentUser: FullUser|null, ctl: IOpenController) { private _buildUserRow(user: UserAccessData, opt: {isExampleUser?: boolean} = {}) {
const isCurrentUser = Boolean(currentUser && user.id === currentUser.id); return dom('a',
return cssUserItem( {class: cssMemberListItem.className + ' ' + cssUserItem.className},
cssMemberImage( cssMemberImage(
createUserImage(user, 'large') createUserImage(opt.isExampleUser ? 'exampleUser' : user, 'large')
), ),
cssMemberText( cssMemberText(
cssMemberPrimary(user.name || dom('span', user.email), 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 user.name ? cssMemberSecondary(user.email) : null
), ),
basicButton(cssUserButton.cls(''), icon('Copy'), 'Copy Email', this._viewAs(user),
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),
),
testId('acl-user-item'), testId('acl-user-item'),
); );
} }
@ -132,6 +122,9 @@ const cssUserItem = styled(cssMemberListItem, `
&:hover { &:hover {
background-color: ${theme.lightHover}; background-color: ${theme.lightHover};
} }
&, &:hover, &:focus {
text-decoration: none;
}
`); `);
const cssRole = styled('span', ` const cssRole = styled('span', `
@ -139,18 +132,10 @@ const cssRole = styled('span', `
font-weight: normal; font-weight: normal;
`); `);
const cssUserButton = styled('div', ` const cssHeader = styled('div', `
margin: 0 8px; margin: 11px 24px 14px 24px;
border: none; font-weight: 700;
display: inline-flex; text-transform: uppercase;
white-space: nowrap; font-size: ${vars.xsmallFontSize};
gap: 4px; color: ${theme.darkText};
&:hover {
--icon-color: ${theme.controlFg};
color: ${theme.controlFg};
background-color: ${theme.hover};
}
&-disabled {
visibility: hidden;
}
`); `);

View File

@ -356,7 +356,8 @@ export class AccessRules extends Disposable {
), ),
), ),
bigBasicButton(t('AddUserAttributes'), dom.on('click', () => this._addUserAttributes())), 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')), dom.style('visibility', use => use(this._aclUsersPopup.isInitialized) ? '' : 'hidden')),
), ),
cssConditionError({style: 'margin-left: 16px'}, cssConditionError({style: 'margin-left: 16px'},

View File

@ -16,7 +16,7 @@ export interface BannerOptions {
* Warning banners have a yellow background. Error banners have a red * Warning banners have a yellow background. Error banners have a red
* background. * background.
*/ */
style: 'warning' | 'error'; style: 'warning' | 'error' | 'info';
/** /**
* Optional variant of `content` to display when screen width becomes narrow. * Optional variant of `content` to display when screen width becomes narrow.
@ -40,6 +40,11 @@ export interface BannerOptions {
*/ */
showExpandButton?: boolean; 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. * Function that is called when the banner close button is clicked.
* *
@ -59,7 +64,7 @@ export class Banner extends Disposable {
} }
public buildDom() { public buildDom() {
return cssBanner( return cssBanner({class: this._options.bannerCssClass || ''},
cssBanner.cls(`-${this._options.style}`), cssBanner.cls(`-${this._options.style}`),
this._buildContent(), this._buildContent(),
this._buildButtons(), this._buildButtons(),
@ -114,6 +119,11 @@ const cssBanner = styled('div', `
gap: 16px; gap: 16px;
color: white; color: white;
&-info {
color: black;
background: #FFFACD;
}
&-warning { &-warning {
background: #E6A117; background: #E6A117;
} }

View 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;
`);

View File

@ -1,4 +1,5 @@
import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners'; import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners';
import {ViewAsBanner} from 'app/client/components/ViewAsBanner';
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';
@ -161,5 +162,6 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App)
testId, testId,
contentTop: buildDocumentBanners(pageModel), contentTop: buildDocumentBanners(pageModel),
contentBottom: dom.create(createBottomBarDoc, pageModel, leftPanelOpen, rightPanelOpen), contentBottom: dom.create(createBottomBarDoc, pageModel, leftPanelOpen, rightPanelOpen),
banner: dom.create(ViewAsBanner, pageModel),
}); });
} }

View File

@ -9,7 +9,9 @@ import {transition, TransitionWatcher} from 'app/client/ui/transitions';
import {cssHideForNarrowScreen, isScreenResizing, mediaNotSmall, mediaSmall, theme} from 'app/client/ui2018/cssVars'; import {cssHideForNarrowScreen, isScreenResizing, mediaNotSmall, mediaSmall, theme} from 'app/client/ui2018/cssVars';
import {isNarrowScreenObs} from 'app/client/ui2018/cssVars'; import {isNarrowScreenObs} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; 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 noop from 'lodash/noop';
import once from 'lodash/once'; import once from 'lodash/once';
import {SessionObs} from 'app/client/lib/sessionObs'; import {SessionObs} from 'app/client/lib/sessionObs';
@ -36,6 +38,7 @@ export interface PageContents {
headerMain: DomElementArg; headerMain: DomElementArg;
contentMain: DomElementArg; contentMain: DomElementArg;
banner?: DomElementArg;
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;
@ -50,12 +53,15 @@ export function pagePanels(page: PageContents) {
const onResize = page.onResize || (() => null); const onResize = page.onResize || (() => null);
const leftOverlap = Observable.create(null, false); const leftOverlap = Observable.create(null, false);
const dragResizer = Observable.create(null, false); const dragResizer = Observable.create(null, false);
const bannerHeight = Observable.create(null, 0);
const isScreenResizingObs = isScreenResizing(); const isScreenResizingObs = isScreenResizing();
let lastLeftOpen = left.panelOpen.get(); let lastLeftOpen = left.panelOpen.get();
let lastRightOpen = right?.panelOpen.get() || false; let lastRightOpen = right?.panelOpen.get() || false;
let leftPaneDom: HTMLElement; let leftPaneDom: HTMLElement;
let rightPaneDom: HTMLElement; let rightPaneDom: HTMLElement;
let mainHeaderDom: HTMLElement;
let contentTopDom: HTMLElement;
let onLeftTransitionFinish = noop; let onLeftTransitionFinish = noop;
// When switching to mobile mode, close panels; when switching to desktop, restore the // 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(sub2),
dom.autoDispose(commandsGroup), dom.autoDispose(commandsGroup),
dom.autoDispose(leftOverlap), 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( cssContentMain(
leftPaneDom = cssLeftPane( leftPaneDom = cssLeftPane(
testId('left-panel'), testId('left-panel'),
cssOverflowContainer( cssOverflowContainer(
contentWrapper = cssLeftPanelContainer( contentWrapper = cssLeftPanelContainer(
cssLeftPaneHeader(left.header), cssLeftPaneHeader(
left.header,
dom.style('margin-bottom', use => use(bannerHeight) + 'px')
),
left.content, left.content,
), ),
), ),
@ -242,7 +265,7 @@ export function pagePanels(page: PageContents) {
cssHideForNarrowScreen.cls('')), cssHideForNarrowScreen.cls('')),
cssMainPane( cssMainPane(
cssTopHeader( mainHeaderDom = cssTopHeader(
testId('top-header'), testId('top-header'),
(left.hideOpener ? null : (left.hideOpener ? null :
cssPanelOpener('PanelRight', cssPanelOpener.cls('-open', left.panelOpen), cssPanelOpener('PanelRight', cssPanelOpener.cls('-open', left.panelOpen),
@ -260,6 +283,7 @@ export function pagePanels(page: PageContents) {
dom.on('click', () => toggleObs(right.panelOpen)), dom.on('click', () => toggleObs(right.panelOpen)),
cssHideForNarrowScreen.cls('')) cssHideForNarrowScreen.cls(''))
), ),
dom.style('margin-bottom', use => use(bannerHeight) + 'px'),
), ),
page.contentMain, page.contentMain,
cssMainPane.cls('-left-overlap', leftOverlap), cssMainPane.cls('-left-overlap', leftOverlap),
@ -275,7 +299,10 @@ export function pagePanels(page: PageContents) {
rightPaneDom = cssRightPane( rightPaneDom = cssRightPane(
testId('right-panel'), testId('right-panel'),
cssRightPaneHeader(right.header), cssRightPaneHeader(
right.header,
dom.style('margin-bottom', use => use(bannerHeight) + 'px')
),
right.content, right.content,
dom.style('width', (use) => use(right.panelOpen) ? use(right.panelWidth) + 'px' : ''), dom.style('width', (use) => use(right.panelOpen) ? use(right.panelWidth) + 'px' : ''),
@ -606,7 +633,11 @@ const cssHiddenInput = styled('input', `
font-size: 1; font-size: 1;
z-index: -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 // 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 // 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. // 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(); 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();
}

View File

@ -7,13 +7,10 @@ import {buildExamples} from 'app/client/ui/ExampleInfo';
import {createHelpTools, cssLinkText, cssPageEntry, cssPageEntryMain, cssPageEntrySmall, import {createHelpTools, cssLinkText, cssPageEntry, cssPageEntryMain, cssPageEntrySmall,
cssPageIcon, cssPageLink, cssSectionHeader, cssSpacer, cssSplitPageEntry, cssPageIcon, cssPageLink, cssSectionHeader, cssSpacer, cssSplitPageEntry,
cssTools} from 'app/client/ui/LeftPanelCommon'; cssTools} from 'app/client/ui/LeftPanelCommon';
import {hoverTooltip, tooltipCloseButton} from 'app/client/ui/tooltips';
import {theme} from 'app/client/ui2018/cssVars'; import {theme} 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 {menuAnnotate} from 'app/client/ui2018/menus'; import {menuAnnotate} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals'; import {confirmModal} from 'app/client/ui2018/modals';
import {userOverrideParams} from 'app/common/gristUrls';
import {isOwner} from 'app/common/roles'; import {isOwner} from 'app/common/roles';
import {Disposable, dom, makeTestId, Observable, observable, styled} from 'grainjs'; 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('')) menuAnnotate('Beta', cssBetaTag.cls(''))
), ),
_canViewAccessRules ? urlState().setLinkUrl({docPage: 'acl'}) : null, _canViewAccessRules ? urlState().setLinkUrl({docPage: 'acl'}) : null,
isOverridden ? addRevertViewAsUI() : null,
); );
}), }),
testId('access-rules'), testId('access-rules'),
@ -178,49 +174,6 @@ export interface AutomaticHelpToolInfo {
markAsSeen: () => void; 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', ` const cssExampleCardOpener = styled('div', `
cursor: pointer; cursor: pointer;
margin-right: 4px; 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', ` const cssBetaTag = styled('div', `
.${cssPageEntry.className}-disabled & { .${cssPageEntry.className}-disabled & {
opacity: 0.4; opacity: 0.4;

View File

@ -82,7 +82,6 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
isFork: pageModel.isFork, isFork: pageModel.isFork,
isBareFork: pageModel.isBareFork, isBareFork: pageModel.isBareFork,
isRecoveryMode: pageModel.isRecoveryMode, isRecoveryMode: pageModel.isRecoveryMode,
userOverride: pageModel.userOverride,
isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork)), isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork)),
isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)), isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)),
isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)), isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)),

View File

@ -1,6 +1,7 @@
import {colors, theme} from 'app/client/ui2018/cssVars'; import {colors, theme} from 'app/client/ui2018/cssVars';
import {FullUser} from 'app/common/LoginSessionAPI'; import {FullUser} from 'app/common/LoginSessionAPI';
import {dom, DomElementArg, styled} from 'grainjs'; import {dom, DomElementArg, styled} from 'grainjs';
import {icon} from 'app/client/ui2018/icons';
export type Size = 'small' | 'medium' | 'large'; 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 * 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. * 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; let initials: string;
return cssUserImage( return cssUserImage(
cssUserImage.cls('-' + size), cssUserImage.cls('-' + size),
(user === 'exampleUser') ? [cssUserImage.cls('-example'), cssExampleUserIcon('EyeShow')] :
(!user || user.anonymous) ? cssUserImage.cls('-anon') : (!user || user.anonymous) ? cssUserImage.cls('-anon') :
[ [
(user.picture ? cssUserPicture({src: user.picture}, dom.on('error', (ev, el) => dom.hideElem(el, true))) : null), (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 { &-reduced {
font-size: var(--reduced-font-size); font-size: var(--reduced-font-size);
} }
&-example {
background-color: ${colors.slate};
border: 1px solid ${colors.slate};
}
`); `);
const cssUserPicture = styled('img', ` const cssUserPicture = styled('img', `
@ -124,3 +131,10 @@ const cssUserPicture = styled('img', `
border-radius: 100px; border-radius: 100px;
box-sizing: content-box; /* keep the border outside of the size of the image */ 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);
`);

View File

@ -11,8 +11,6 @@ import { cssHideForNarrowScreen, mediaNotSmall, testId, theme } from 'app/client
import { editableLabel } from 'app/client/ui2018/editableLabel'; import { editableLabel } from 'app/client/ui2018/editableLabel';
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 { UserOverride } from 'app/common/DocListAPI';
import { userOverrideParams } from 'app/common/gristUrls';
import { BindableValue, dom, Observable, styled } from 'grainjs'; import { BindableValue, dom, Observable, styled } from 'grainjs';
import { tooltip } from 'popweasel'; import { tooltip } from 'popweasel';
@ -99,7 +97,6 @@ export function docBreadcrumbs(
isBareFork: Observable<boolean>, isBareFork: Observable<boolean>,
isFiddle: Observable<boolean>, isFiddle: Observable<boolean>,
isRecoveryMode: Observable<boolean>, isRecoveryMode: Observable<boolean>,
userOverride: Observable<UserOverride|null>,
isSnapshot?: Observable<boolean>, isSnapshot?: Observable<boolean>,
isPublic?: Observable<boolean>, isPublic?: Observable<boolean>,
} }
@ -152,16 +149,6 @@ export function docBreadcrumbs(
icon('CrossSmall')), icon('CrossSmall')),
testId('recovery-mode-tag')); 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)) { if (use(options.isFiddle)) {
return cssTag(t('Fiddle'), tooltip({title: t('FiddleExplanation')}), testId('fiddle-tag')); return cssTag(t('Fiddle'), tooltip({title: t('FiddleExplanation')}), testId('fiddle-tag'));
} }

View File

@ -1,5 +1,5 @@
import {ActionSummary} from 'app/common/ActionSummary'; 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 {BaseAPI, IOptions} from 'app/common/BaseAPI';
import {BillingAPI, BillingAPIImpl} from 'app/common/BillingAPI'; import {BillingAPI, BillingAPIImpl} from 'app/common/BillingAPI';
import {BrowserSettings} from 'app/common/BrowserSettings'; import {BrowserSettings} from 'app/common/BrowserSettings';
@ -199,6 +199,16 @@ export function getRealAccess(user: UserAccessData, permissionData: PermissionDa
return roles.getStrongestRole(user.access, inheritedAccess); 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 { export interface ActiveSessionInfo {
user: FullUser & {helpScoutSignature?: string}; user: FullUser & {helpScoutSignature?: string};
org: Organization|null; org: Organization|null;
@ -401,6 +411,9 @@ export interface DocAPI {
// Upload a single attachment and return the resulting metadata row ID. // Upload a single attachment and return the resulting metadata row ID.
// The arguments are passed to FormData.append. // The arguments are passed to FormData.append.
uploadAttachment(value: string | Blob, filename?: string): Promise<number>; 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. // 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> { public async forceReload(): Promise<void> {
await this.request(`${this._url}/force-reload`, { await this.request(`${this._url}/force-reload`, {
method: 'POST' method: 'POST'

View File

@ -50,6 +50,7 @@ export class DocApiForwarder {
app.use('/api/docs/:docId/apply', withDoc); app.use('/api/docs/:docId/apply', withDoc);
app.use('/api/docs/:docId/attachments', withDoc); app.use('/api/docs/:docId/attachments', withDoc);
app.use('/api/docs/:docId/snapshots', 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/replace', withDoc);
app.use('/api/docs/:docId/flush', withDoc); app.use('/api/docs/:docId/flush', withDoc);
app.use('/api/docs/:docId/states', withDoc); app.use('/api/docs/:docId/states', withDoc);

View File

@ -1504,7 +1504,7 @@ export class ActiveDoc extends EventEmitter {
* *
* Example users are always included. * 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. // Make sure we have rights to view access rules.
const db = this.getHomeDbManager(); const db = this.getHomeDbManager();
if (!db || !await this._granularAccess.hasAccessRulesPermission(docSession)) { if (!db || !await this._granularAccess.hasAccessRulesPermission(docSession)) {

View File

@ -673,6 +673,11 @@ export class DocWorkerApi {
res.json({snapshots}); 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) => { this._app.post('/api/docs/:docId/snapshots/remove', isOwner, withDoc(async (activeDoc, req, res) => {
const docSession = docSessionFromRequest(req); const docSession = docSessionFromRequest(req);
const snapshotIds = req.body.snapshotIds as string[]; const snapshotIds = req.body.snapshotIds as string[];

View File

@ -567,7 +567,6 @@
"Reset": "Reset", "Reset": "Reset",
"AddTableRules": "Add Table Rules", "AddTableRules": "Add Table Rules",
"AddUserAttributes": "Add User Attributes", "AddUserAttributes": "Add User Attributes",
"Users": "Users",
"UserAttributes": "User Attributes", "UserAttributes": "User Attributes",
"AttributeToLookUp": "Attribute to Look Up", "AttributeToLookUp": "Attribute to Look Up",
"LookupTable": "Lookup Table", "LookupTable": "Lookup Table",
@ -591,7 +590,13 @@
"RemoveRulesMentioningTable": "Remove {{- tableId }} rules", "RemoveRulesMentioningTable": "Remove {{- tableId }} rules",
"RemoveRulesMentioningColumn": "Remove column {{- colId }} from {{- tableId }} rules", "RemoveRulesMentioningColumn": "Remove column {{- colId }} from {{- tableId }} rules",
"RemoveUserAttribute": "Remove {{- name }} user attribute", "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": { "PermissionsWidget": {
"AllowAll": "Allow All", "AllowAll": "Allow All",
@ -729,7 +734,7 @@
"PluginColon": "Plugin: ", "PluginColon": "Plugin: ",
"SectionColon": "Section: " "SectionColon": "Section: "
}, },
"Drafts": { "Drafts": {
"UndoDiscard":"Undo discard", "UndoDiscard":"Undo discard",
"RestoreLastEdit":"Restore last edit" "RestoreLastEdit":"Restore last edit"
}, },
@ -777,6 +782,9 @@
"ValidationPanel": { "ValidationPanel": {
"RuleLength":"Rule {{length}}", "RuleLength":"Rule {{length}}",
"UpdateFormula":"Update formula (Shift+Enter)" "UpdateFormula":"Update formula (Shift+Enter)"
},
"ViewAsBanner": {
"UnknownUser": "Unknown User"
} }
} }
} }