diff --git a/app/client/aclui/ACLUsers.ts b/app/client/aclui/ACLUsers.ts index c0e5bac8..e7875319 100644 --- a/app/client/aclui/ACLUsers.ts +++ b/app/client/aclui/ACLUsers.ts @@ -7,11 +7,11 @@ import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons'; import {colors, testId} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menuCssClass} from 'app/client/ui2018/menus'; +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, getRealAccess, UserAccessData} from 'app/common/UserAPI'; import {Disposable, dom, Observable, styled} from 'grainjs'; -import merge = require('lodash/merge'); import {cssMenu, cssMenuWrap, defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel'; const roleNames: {[role: string]: string} = { @@ -38,10 +38,9 @@ function buildUserRow(user: UserAccessData, currentUser: FullUser|null, ctl: IOp ), basicButtonLink(cssUserButton.cls(''), cssUserButton.cls('-disabled', isCurrentUser), testId('acl-user-view-as'), - icon('FieldLink'), 'View As', { - href: urlState().makeUrl( - merge({}, urlState().state.get(), {docPage: '', params: {linkParameters: {aclAsUser: user.email}}})), - }), + icon('FieldLink'), 'View As', + urlState().setHref(userOverrideParams(user.email, {docPage: undefined})), + ), testId('acl-user-item'), ); } diff --git a/app/client/lib/UrlState.ts b/app/client/lib/UrlState.ts index faddb088..a78d090f 100644 --- a/app/client/lib/UrlState.ts +++ b/app/client/lib/UrlState.ts @@ -33,10 +33,12 @@ export interface UrlStateSpec { delayPushUrl(prevState: IUrlState, newState: IUrlState): Promise; } +export type UpdateFunc = (prevState: IUrlState) => IUrlState; + /** * Represents the state of a page in browser history, as encoded in window.location URL. */ -export class UrlState extends Disposable { +export class UrlState extends Disposable { // Current state. This gets initialized in the constructor, and updated on navigation events. public state = observable(this._getState()); @@ -56,9 +58,11 @@ export class UrlState extends Disposable { * Creates a new history entry (navigable with Back/Forward buttons), encoding the given state * in the URL. This is similar to navigating to a new URL, but does not reload the page. */ - public async pushUrl(urlState: IUrlState, options: {replace?: boolean, avoidReload?: boolean} = {}) { + public async pushUrl(urlState: IUrlState|UpdateFunc, + options: {replace?: boolean, avoidReload?: boolean} = {}) { const prevState = this.state.get(); - const newState = this._stateImpl.updateState(prevState, urlState); + const newState = this._mergeState(prevState, urlState); + const newUrl = this._stateImpl.encodeUrl(newState, this._window.location); // Don't create a new history entry if nothing changed as it would only be annoying. @@ -87,19 +91,32 @@ export class UrlState extends Disposable { /** * Creates a URL (e.g. to use in a link's href) encoding the given state. The `use` argument - * allows for this to be used in a computed, and is used by setLinkUrl(). + * allows for this to be used in a computed, and is used by setLinkUrl() and setHref(). + * + * If urlState is an object (such as IGristUrlState), it gets merged with previous state + * according to rules (in gristUrlState's updateState). Alternatively, it can be a function that + * takes previous state and returns the new one. */ - public makeUrl(urlState: IUrlState, use: UseCB = unwrap): string { - const fullState = this._stateImpl.updateState(use(this.state), urlState); + public makeUrl(urlState: IUrlState|UpdateFunc, use: UseCB = unwrap): string { + const fullState = this._mergeState(use(this.state), urlState); return this._stateImpl.encodeUrl(fullState, this._window.location); } + /** + * Sets href on a dom element, e.g. dom('a', setHref({...})). + * This is similar to {href: makeUrl(urlState)}, but the destination URL will reflect the + * current url state (e.g. due to switching pages). + */ + public setHref(urlState: IUrlState|UpdateFunc): DomElementMethod { + return dom.attr('href', (use) => this.makeUrl(urlState, use)); + } + /** * Applies to an element to create a smart link, e.g. dom('a', setLinkUrl({ws: wsId})). It * both sets the href (e.g. to allow the link to be opened to a new tab), AND intercepts plain * clicks on it to "follow" the link without reloading the page. */ - public setLinkUrl(urlState: IUrlState): DomElementMethod[] { + public setLinkUrl(urlState: IUrlState|UpdateFunc): DomElementMethod[] { return [ dom.attr('href', (use) => this.makeUrl(urlState, use)), dom.on('click', (ev) => { @@ -123,6 +140,12 @@ export class UrlState extends Disposable { private _getState(): IUrlState { return this._stateImpl.decodeUrl(this._window.location); } + + private _mergeState(prevState: IUrlState, newState: IUrlState|UpdateFunc): IUrlState { + return (typeof newState === 'object') ? + this._stateImpl.updateState(prevState, newState) : + newState(prevState); + } } // This is what we expect from the global Window object. Tests may override with a mock. diff --git a/app/client/ui/LeftPanelCommon.ts b/app/client/ui/LeftPanelCommon.ts index f621f350..608b9272 100644 --- a/app/client/ui/LeftPanelCommon.ts +++ b/app/client/ui/LeftPanelCommon.ts @@ -131,19 +131,6 @@ export const cssPageLink = styled('a', ` } `); -// Styled like a cssPageLink, but in a disabled mode, without an actual link. -export const cssPageDisabledLink = styled('span', ` - display: flex; - align-items: center; - height: 32px; - line-height: 32px; - padding-left: 24px; - outline: none; - text-decoration: none; - outline: none; - color: inherit; -`); - export const cssLinkText = styled('span', ` white-space: nowrap; overflow: hidden; diff --git a/app/client/ui/Tools.ts b/app/client/ui/Tools.ts index fb198dd2..a1b93d05 100644 --- a/app/client/ui/Tools.ts +++ b/app/client/ui/Tools.ts @@ -3,9 +3,12 @@ import { urlState } from "app/client/models/gristUrlState"; import { showExampleCard } from 'app/client/ui/ExampleCard'; import { examples } from 'app/client/ui/ExampleInfo'; import { createHelpTools, cssSectionHeader, cssSpacer, cssTools } from 'app/client/ui/LeftPanelCommon'; -import { cssLinkText, cssPageDisabledLink, cssPageEntry, cssPageIcon, cssPageLink } from 'app/client/ui/LeftPanelCommon'; +import { cssLinkText, cssPageEntry, cssPageIcon, cssPageLink } from 'app/client/ui/LeftPanelCommon'; +import { hoverTooltip, tooltipCloseButton } from 'app/client/ui/tooltips'; import { colors } from 'app/client/ui2018/cssVars'; import { icon } from 'app/client/ui2018/icons'; +import { cssLink } from 'app/client/ui2018/links'; +import { userOverrideParams } from 'app/common/gristUrls'; import { Disposable, dom, makeTestId, Observable, styled } from "grainjs"; const testId = makeTestId('test-tools-'); @@ -22,10 +25,11 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse (aclUIEnabled ? cssPageEntry( cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'), - cssPageEntry.cls('-disabled', !canUseAccessRules), - (canUseAccessRules ? cssPageLink : cssPageDisabledLink)(cssPageIcon('EyeShow'), + cssPageEntry.cls('-disabled', !isOwner), + cssPageLink(cssPageIcon('EyeShow'), cssLinkText('Access Rules'), - canUseAccessRules ? urlState().setLinkUrl({docPage: 'acl'}) : null + canUseAccessRules ? urlState().setLinkUrl({docPage: 'acl'}) : null, + isOverridden ? addRevertViewAsUI() : null, ), testId('access-rules'), ) : @@ -83,6 +87,43 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse ); } +// 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('Return to viewing as yourself', + urlState().setHref(userOverrideParams(null, {docPage: 'acl'})), + ), + tooltipCloseButton(ctl), + ), + {openOnClick: true} + ), + ]; +} + +const cssConvertTooltip = styled('div', ` + display: flex; + align-items: center; + --icon-color: ${colors.lightGreen}; + + & > .${cssLink.className} { + margin-left: 8px; + } +`); + const cssExampleCardOpener = styled('div', ` cursor: pointer; margin-right: 4px; @@ -98,4 +139,14 @@ const cssExampleCardOpener = styled('div', ` &:hover { background-color: ${colors.darkGreen}; } + .${cssTools.className}-collapsed & { + display: none; + } +`); + +const cssRevertViewAsButton = styled(cssExampleCardOpener, ` + background-color: ${colors.darkGrey}; + &:hover { + background-color: ${colors.slate}; + } `); diff --git a/app/client/ui/TopBar.ts b/app/client/ui/TopBar.ts index a2daddd7..6e12f27e 100644 --- a/app/client/ui/TopBar.ts +++ b/app/client/ui/TopBar.ts @@ -44,7 +44,6 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode docNameSave: renameDoc, pageNameSave: getRenamePageFn(gristDoc), cancelRecoveryMode: getCancelRecoveryModeFn(gristDoc), - cancelUserOverride: getCancelUserOverrideFn(gristDoc), isPageNameReadOnly: (use) => use(gristDoc.isReadonly) || typeof use(gristDoc.activeViewId) !== 'number', isDocNameReadOnly: (use) => use(gristDoc.isReadonly) || use(pageModel.isFork), isFork: pageModel.isFork, @@ -105,15 +104,6 @@ function getCancelRecoveryModeFn(gristDoc: GristDoc): () => Promise { }; } -function getCancelUserOverrideFn(gristDoc: GristDoc): () => Promise { - return async () => { - const url = new URL(window.location.href); - url.searchParams.delete('aclAsUser_'); - url.searchParams.delete('aclAsUserId_'); - window.location.assign(url.href); - }; -} - function topBarUndoBtn(iconName: IconName, ...domArgs: DomElementArg[]): Element { return cssHoverCircle( cssTopBarUndoBtn(iconName), diff --git a/app/client/ui/tooltips.ts b/app/client/ui/tooltips.ts index f903222a..21c824c1 100644 --- a/app/client/ui/tooltips.ts +++ b/app/client/ui/tooltips.ts @@ -7,7 +7,8 @@ import {prepareForTransition} from 'app/client/ui/transitions'; import {testId} from 'app/client/ui2018/cssVars'; -import {dom, DomContents, styled} from 'grainjs'; +import {icon} from 'app/client/ui2018/icons'; +import {dom, DomContents, DomElementMethod, styled} from 'grainjs'; import Popper from 'popper.js'; export interface ITipOptions { @@ -24,8 +25,27 @@ export interface ITransientTipOptions extends ITipOptions { timeoutMs?: number; } +export interface IHoverTipOptions extends ITransientTipOptions { + // How soon after mouseenter to show it. Defaults to 100 ms. + openDelay?: number; + + // If set and non-zero, remove the tip automatically after this time. + timeoutMs?: number; + + // How soon after mouseleave to hide it. Defaults to 400 ms. It also gives the pointer some time + // to be outside of the trigger and the tooltip content if the user moves the pointer from one + // to the other. + closeDelay?: number; + + // Also show the tip on clicking the element it's attached to. + openOnClick?: boolean; +} + +export type ITooltipContentFunc = (ctl: ITooltipControl) => DomContents; + export interface ITooltipControl { close(): void; + getDom(): HTMLElement; // The tooltip DOM. } // Map of open tooltips, mapping the key (from ITipOptions) to ITooltipControl that allows @@ -50,7 +70,7 @@ export function showTransientTooltip(refElem: Element, tipContent: DomContents, * See also ITipOptions. */ export function showTooltip( - refElem: Element, tipContent: (ctl: ITooltipControl) => DomContents, options: ITipOptions = {} + refElem: Element, tipContent: ITooltipContentFunc, options: ITipOptions = {} ): ITooltipControl { const placement: Popper.Placement = options.placement || 'top'; const key = options.key; @@ -65,10 +85,10 @@ export function showTooltip( content.remove(); if (key) { openTooltips.delete(key); } } - const ctl: ITooltipControl = {close}; + const ctl: ITooltipControl = {close, getDom: () => content}; // Add the content element. - const content = cssTooltip({role: 'tooltip'}, tipContent(ctl), testId(`transient-tooltip`)); + const content = cssTooltip({role: 'tooltip'}, tipContent(ctl), testId(`tooltip`)); document.body.appendChild(content); // Create a popper for positioning the tooltip content relative to refElem. @@ -86,11 +106,83 @@ export function showTooltip( return ctl; } +/** + * Render a tooltip on hover. Suitable for use during dom construction, e.g. + * dom('div', 'Trigger', hoverTooltip(() => 'Hello!')) + */ +export function hoverTooltip(tipContent: ITooltipContentFunc, options?: IHoverTipOptions): DomElementMethod { + return (elem) => setHoverTooltip(elem, tipContent, options); +} + +/** + * Attach a tooltip to the given element, to be rendered on hover. + */ +export function setHoverTooltip(refElem: Element, tipContent: ITooltipContentFunc, options: IHoverTipOptions = {}) { + const {openDelay = 100, timeoutMs, closeDelay = 400} = options; + + // Controller for closing the tooltip, if one is open. + let tipControl: ITooltipControl|undefined; + + // Timer to open or close the tooltip, depending on whether tipControl is set. + let timer: ReturnType|undefined; + + function clearTimer() { + if (timer) { clearTimeout(timer); timer = undefined; } + } + function resetTimer(func: () => void, delay: number) { + clearTimer(); + timer = setTimeout(func, delay); + } + function scheduleCloseIfOpen() { + clearTimer(); + if (tipControl) { resetTimer(close, closeDelay); } + } + function open() { + clearTimer(); + tipControl = showTooltip(refElem, ctl => tipContent({...ctl, close}), options); + dom.onElem(tipControl.getDom(), 'mouseenter', clearTimer); + dom.onElem(tipControl.getDom(), 'mouseleave', scheduleCloseIfOpen); + if (timeoutMs) { resetTimer(close, timeoutMs); } + } + function close() { + clearTimer(); + tipControl?.close(); + tipControl = undefined; + } + + // We simulate hover effect by handling mouseenter/mouseleave. + dom.onElem(refElem, 'mouseenter', () => { + if (!tipControl && !timer) { + resetTimer(open, openDelay); + } else if (tipControl) { + // Already shown, reset to newly-shown state. + clearTimer(); + if (timeoutMs) { resetTimer(close, timeoutMs); } + } + }); + + dom.onElem(refElem, 'mouseleave', scheduleCloseIfOpen); + + if (options.openOnClick) { + // If request, re-open on click. + dom.onElem(refElem, 'click', () => { close(); open(); }); + } +} + +/** + * Build a handy button for closing a tooltip. + */ +export function tooltipCloseButton(ctl: ITooltipControl): HTMLElement { + return cssTooltipCloseButton(icon('CrossSmall'), + dom.on('click', () => ctl.close()), + testId('tooltip-close'), + ); +} const cssTooltip = styled('div', ` position: absolute; z-index: 5000; /* should be higher than a modal */ - background-color: black; + background-color: rgba(0, 0, 0, 0.75); border-radius: 3px; box-shadow: 0 0 2px rgba(0,0,0,0.5); text-align: center; @@ -100,6 +192,22 @@ const cssTooltip = styled('div', ` font-size: 10pt; padding: 8px 16px; margin: 4px; - opacity: 0.75; transition: opacity 0.2s; `); + +const cssTooltipCloseButton = styled('div', ` + cursor: pointer; + user-select: none; + width: 16px; + height: 16px; + line-height: 16px; + text-align: center; + margin: -4px -4px -4px 8px; + --icon-color: white; + border-radius: 16px; + + &:hover { + background-color: white; + --icon-color: black; + } +`); diff --git a/app/client/ui2018/breadcrumbs.ts b/app/client/ui2018/breadcrumbs.ts index 07a8e0cb..34f61e97 100644 --- a/app/client/ui2018/breadcrumbs.ts +++ b/app/client/ui2018/breadcrumbs.ts @@ -10,6 +10,7 @@ import { colors, cssHideForNarrowScreen, mediaNotSmall, testId } from 'app/clien import { editableLabel } from 'app/client/ui2018/editableLabel'; import { icon } from 'app/client/ui2018/icons'; import { UserOverride } from 'app/common/DocListAPI'; +import { userOverrideParams } from 'app/common/gristUrls'; import { BindableValue, dom, Observable, styled } from 'grainjs'; import { tooltip } from 'popweasel'; @@ -102,7 +103,6 @@ export function docBreadcrumbs( docNameSave: (val: string) => Promise, pageNameSave: (val: string) => Promise, cancelRecoveryMode: () => Promise, - cancelUserOverride: () => Promise, isDocNameReadOnly?: BindableValue, isPageNameReadOnly?: BindableValue, isFork: Observable, @@ -154,9 +154,12 @@ export function docBreadcrumbs( const userOverride = use(options.userOverride); if (userOverride) { return cssAlertTag(userOverride.user?.email || 'override', - dom('a', dom.on('click', () => options.cancelUserOverride()), - icon('CrossSmall')), - testId('user-override-tag')); + dom('a', + urlState().setHref(userOverrideParams(null)), + icon('CrossSmall') + ), + testId('user-override-tag') + ); } if (use(options.isFiddle)) { return cssTag('fiddle', tooltip({title: fiddleExplanation}), testId('fiddle-tag')); diff --git a/app/client/widgets/EditorTooltip.ts b/app/client/widgets/EditorTooltip.ts index fa1029ff..49457b87 100644 --- a/app/client/widgets/EditorTooltip.ts +++ b/app/client/widgets/EditorTooltip.ts @@ -1,4 +1,4 @@ -import {ITooltipControl, showTooltip} from 'app/client/ui/tooltips'; +import {ITooltipControl, showTooltip, tooltipCloseButton} from 'app/client/ui/tooltips'; import {colors, testId} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; @@ -11,7 +11,7 @@ export function showTooltipToCreateFormula(editorDom: HTMLElement, convert: () = dom.on('mousedown', (ev) => { ev.preventDefault(); convert(); }), testId('editor-tooltip-convert'), ), - cssCloseButton(icon('CrossSmall'), dom.on('click', ctl.close)), + tooltipCloseButton(ctl), ); } const offerCtl = showTooltip(editorDom, buildTooltip, {key: 'col-to-formula'}); @@ -32,20 +32,3 @@ const cssConvertTooltip = styled('div', ` margin-left: 8px; } `); - -const cssCloseButton = styled('div', ` - cursor: pointer; - user-select: none; - width: 16px; - height: 16px; - line-height: 16px; - text-align: center; - margin: -4px -4px -4px 8px; - --icon-color: white; - border-radius: 16px; - - &:hover { - background-color: white; - --icon-color: black; - } -`); diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index b75e7ba7..8d5a220d 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -297,6 +297,23 @@ export function useNewUI(newui: boolean|undefined) { return newui !== false; } +// Returns a function suitable for user with makeUrl/setHref/etc, which updates aclAsUser* +// linkParameters in the current state, unsetting them if email is null. Optional extraState +// allows setting other properties (e.g. 'docPage') at the same time. +export function userOverrideParams(email: string|null, extraState?: IGristUrlState) { + return function(prevState: IGristUrlState): IGristUrlState { + const combined = {...prevState, ...extraState}; + const linkParameters = combined.params?.linkParameters || {}; + if (email) { + linkParameters.aclAsUser = email; + } else { + delete linkParameters.aclAsUser; + } + delete linkParameters.aclAsUserId; + return {...combined, params: {...combined.params, linkParameters}}; + }; +} + /** * parseDocPage is a noop if p is 'new' or 'code', otherwise parse to integer */