(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

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