(core) Add a button and a tooltip to Access Rules page item, in View-As mode.

Summary:
- When in View-As mode, clicking the Access Rules page now shows a tooltip
  with a link to return to normal mode and open the Access Rules page.
- A "revert" button is shown next to the item with the same behavior.
- Implemented hoverTooltip() with various options. (It will have other uses.)
- Simplify creation of links based on UrlState:
  - Allow merging with previous urlState using a function
  - Add a helper function to merge in aclAsUser parameter.
  - Add setHref() method to UrlState

Test Plan:
Added test cases:
  - for tooltips generally in test/projects
  - for updating UrlState using a callback
  - for Access Rules tooltip and button behavior

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2749
This commit is contained in:
Dmitry S 2021-03-08 16:08:13 -05:00
parent 5e5bf3af9d
commit 3f29baaded
9 changed files with 229 additions and 68 deletions

View File

@ -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'),
);
}

View File

@ -33,10 +33,12 @@ export interface UrlStateSpec<IUrlState> {
delayPushUrl(prevState: IUrlState, newState: IUrlState): Promise<void>;
}
export type UpdateFunc<IUrlState> = (prevState: IUrlState) => IUrlState;
/**
* Represents the state of a page in browser history, as encoded in window.location URL.
*/
export class UrlState<IUrlState> extends Disposable {
export class UrlState<IUrlState extends object> extends Disposable {
// Current state. This gets initialized in the constructor, and updated on navigation events.
public state = observable<IUrlState>(this._getState());
@ -56,9 +58,11 @@ export class UrlState<IUrlState> 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<IUrlState>,
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<IUrlState> 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<IUrlState>, 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<IUrlState>): DomElementMethod {
return dom.attr('href', (use) => this.makeUrl(urlState, use));
}
/**
* Applies to an <a> 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<IUrlState>): DomElementMethod[] {
return [
dom.attr('href', (use) => this.makeUrl(urlState, use)),
dom.on('click', (ev) => {
@ -123,6 +140,12 @@ export class UrlState<IUrlState> extends Disposable {
private _getState(): IUrlState {
return this._stateImpl.decodeUrl(this._window.location);
}
private _mergeState(prevState: IUrlState, newState: IUrlState|UpdateFunc<IUrlState>): 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.

View File

@ -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;

View File

@ -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};
}
`);

View File

@ -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<void> {
};
}
function getCancelUserOverrideFn(gristDoc: GristDoc): () => Promise<void> {
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),

View File

@ -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<typeof setTimeout>|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;
}
`);

View File

@ -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<void>,
pageNameSave: (val: string) => Promise<void>,
cancelRecoveryMode: () => Promise<void>,
cancelUserOverride: () => Promise<void>,
isDocNameReadOnly?: BindableValue<boolean>,
isPageNameReadOnly?: BindableValue<boolean>,
isFork: Observable<boolean>,
@ -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'));

View File

@ -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;
}
`);

View File

@ -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
*/