mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
5e5bf3af9d
commit
3f29baaded
@ -7,11 +7,11 @@ import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons';
|
|||||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {menuCssClass} from 'app/client/ui2018/menus';
|
import {menuCssClass} from 'app/client/ui2018/menus';
|
||||||
|
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 * as roles from 'app/common/roles';
|
||||||
import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, getRealAccess, UserAccessData} from 'app/common/UserAPI';
|
import {ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, getRealAccess, UserAccessData} from 'app/common/UserAPI';
|
||||||
import {Disposable, dom, Observable, styled} from 'grainjs';
|
import {Disposable, dom, Observable, styled} from 'grainjs';
|
||||||
import merge = require('lodash/merge');
|
|
||||||
import {cssMenu, cssMenuWrap, defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
|
import {cssMenu, cssMenuWrap, defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
|
||||||
|
|
||||||
const roleNames: {[role: string]: string} = {
|
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),
|
basicButtonLink(cssUserButton.cls(''), cssUserButton.cls('-disabled', isCurrentUser),
|
||||||
testId('acl-user-view-as'),
|
testId('acl-user-view-as'),
|
||||||
icon('FieldLink'), 'View As', {
|
icon('FieldLink'), 'View As',
|
||||||
href: urlState().makeUrl(
|
urlState().setHref(userOverrideParams(user.email, {docPage: undefined})),
|
||||||
merge({}, urlState().state.get(), {docPage: '', params: {linkParameters: {aclAsUser: user.email}}})),
|
),
|
||||||
}),
|
|
||||||
testId('acl-user-item'),
|
testId('acl-user-item'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -33,10 +33,12 @@ export interface UrlStateSpec<IUrlState> {
|
|||||||
delayPushUrl(prevState: IUrlState, newState: IUrlState): Promise<void>;
|
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.
|
* 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.
|
// Current state. This gets initialized in the constructor, and updated on navigation events.
|
||||||
public state = observable<IUrlState>(this._getState());
|
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
|
* 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.
|
* 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 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);
|
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.
|
// 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
|
* 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 {
|
public makeUrl(urlState: IUrlState|UpdateFunc<IUrlState>, use: UseCB = unwrap): string {
|
||||||
const fullState = this._stateImpl.updateState(use(this.state), urlState);
|
const fullState = this._mergeState(use(this.state), urlState);
|
||||||
return this._stateImpl.encodeUrl(fullState, this._window.location);
|
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
|
* 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
|
* 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.
|
* clicks on it to "follow" the link without reloading the page.
|
||||||
*/
|
*/
|
||||||
public setLinkUrl(urlState: IUrlState): DomElementMethod[] {
|
public setLinkUrl(urlState: IUrlState|UpdateFunc<IUrlState>): DomElementMethod[] {
|
||||||
return [
|
return [
|
||||||
dom.attr('href', (use) => this.makeUrl(urlState, use)),
|
dom.attr('href', (use) => this.makeUrl(urlState, use)),
|
||||||
dom.on('click', (ev) => {
|
dom.on('click', (ev) => {
|
||||||
@ -123,6 +140,12 @@ export class UrlState<IUrlState> extends Disposable {
|
|||||||
private _getState(): IUrlState {
|
private _getState(): IUrlState {
|
||||||
return this._stateImpl.decodeUrl(this._window.location);
|
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.
|
// This is what we expect from the global Window object. Tests may override with a mock.
|
||||||
|
@ -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', `
|
export const cssLinkText = styled('span', `
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -3,9 +3,12 @@ import { urlState } from "app/client/models/gristUrlState";
|
|||||||
import { showExampleCard } from 'app/client/ui/ExampleCard';
|
import { showExampleCard } from 'app/client/ui/ExampleCard';
|
||||||
import { examples } from 'app/client/ui/ExampleInfo';
|
import { examples } from 'app/client/ui/ExampleInfo';
|
||||||
import { createHelpTools, cssSectionHeader, cssSpacer, cssTools } from 'app/client/ui/LeftPanelCommon';
|
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 { colors } 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 { userOverrideParams } from 'app/common/gristUrls';
|
||||||
import { Disposable, dom, makeTestId, Observable, styled } from "grainjs";
|
import { Disposable, dom, makeTestId, Observable, styled } from "grainjs";
|
||||||
|
|
||||||
const testId = makeTestId('test-tools-');
|
const testId = makeTestId('test-tools-');
|
||||||
@ -22,10 +25,11 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
|
|||||||
(aclUIEnabled ?
|
(aclUIEnabled ?
|
||||||
cssPageEntry(
|
cssPageEntry(
|
||||||
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'),
|
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'acl'),
|
||||||
cssPageEntry.cls('-disabled', !canUseAccessRules),
|
cssPageEntry.cls('-disabled', !isOwner),
|
||||||
(canUseAccessRules ? cssPageLink : cssPageDisabledLink)(cssPageIcon('EyeShow'),
|
cssPageLink(cssPageIcon('EyeShow'),
|
||||||
cssLinkText('Access Rules'),
|
cssLinkText('Access Rules'),
|
||||||
canUseAccessRules ? urlState().setLinkUrl({docPage: 'acl'}) : null
|
canUseAccessRules ? urlState().setLinkUrl({docPage: 'acl'}) : null,
|
||||||
|
isOverridden ? addRevertViewAsUI() : null,
|
||||||
),
|
),
|
||||||
testId('access-rules'),
|
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', `
|
const cssExampleCardOpener = styled('div', `
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
@ -98,4 +139,14 @@ const cssExampleCardOpener = styled('div', `
|
|||||||
&:hover {
|
&:hover {
|
||||||
background-color: ${colors.darkGreen};
|
background-color: ${colors.darkGreen};
|
||||||
}
|
}
|
||||||
|
.${cssTools.className}-collapsed & {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssRevertViewAsButton = styled(cssExampleCardOpener, `
|
||||||
|
background-color: ${colors.darkGrey};
|
||||||
|
&:hover {
|
||||||
|
background-color: ${colors.slate};
|
||||||
|
}
|
||||||
`);
|
`);
|
||||||
|
@ -44,7 +44,6 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
|
|||||||
docNameSave: renameDoc,
|
docNameSave: renameDoc,
|
||||||
pageNameSave: getRenamePageFn(gristDoc),
|
pageNameSave: getRenamePageFn(gristDoc),
|
||||||
cancelRecoveryMode: getCancelRecoveryModeFn(gristDoc),
|
cancelRecoveryMode: getCancelRecoveryModeFn(gristDoc),
|
||||||
cancelUserOverride: getCancelUserOverrideFn(gristDoc),
|
|
||||||
isPageNameReadOnly: (use) => use(gristDoc.isReadonly) || typeof use(gristDoc.activeViewId) !== 'number',
|
isPageNameReadOnly: (use) => use(gristDoc.isReadonly) || typeof use(gristDoc.activeViewId) !== 'number',
|
||||||
isDocNameReadOnly: (use) => use(gristDoc.isReadonly) || use(pageModel.isFork),
|
isDocNameReadOnly: (use) => use(gristDoc.isReadonly) || use(pageModel.isFork),
|
||||||
isFork: 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 {
|
function topBarUndoBtn(iconName: IconName, ...domArgs: DomElementArg[]): Element {
|
||||||
return cssHoverCircle(
|
return cssHoverCircle(
|
||||||
cssTopBarUndoBtn(iconName),
|
cssTopBarUndoBtn(iconName),
|
||||||
|
@ -7,7 +7,8 @@
|
|||||||
|
|
||||||
import {prepareForTransition} from 'app/client/ui/transitions';
|
import {prepareForTransition} from 'app/client/ui/transitions';
|
||||||
import {testId} from 'app/client/ui2018/cssVars';
|
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';
|
import Popper from 'popper.js';
|
||||||
|
|
||||||
export interface ITipOptions {
|
export interface ITipOptions {
|
||||||
@ -24,8 +25,27 @@ export interface ITransientTipOptions extends ITipOptions {
|
|||||||
timeoutMs?: number;
|
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 {
|
export interface ITooltipControl {
|
||||||
close(): void;
|
close(): void;
|
||||||
|
getDom(): HTMLElement; // The tooltip DOM.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map of open tooltips, mapping the key (from ITipOptions) to ITooltipControl that allows
|
// 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.
|
* See also ITipOptions.
|
||||||
*/
|
*/
|
||||||
export function showTooltip(
|
export function showTooltip(
|
||||||
refElem: Element, tipContent: (ctl: ITooltipControl) => DomContents, options: ITipOptions = {}
|
refElem: Element, tipContent: ITooltipContentFunc, options: ITipOptions = {}
|
||||||
): ITooltipControl {
|
): ITooltipControl {
|
||||||
const placement: Popper.Placement = options.placement || 'top';
|
const placement: Popper.Placement = options.placement || 'top';
|
||||||
const key = options.key;
|
const key = options.key;
|
||||||
@ -65,10 +85,10 @@ export function showTooltip(
|
|||||||
content.remove();
|
content.remove();
|
||||||
if (key) { openTooltips.delete(key); }
|
if (key) { openTooltips.delete(key); }
|
||||||
}
|
}
|
||||||
const ctl: ITooltipControl = {close};
|
const ctl: ITooltipControl = {close, getDom: () => content};
|
||||||
|
|
||||||
// Add the content element.
|
// 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);
|
document.body.appendChild(content);
|
||||||
|
|
||||||
// Create a popper for positioning the tooltip content relative to refElem.
|
// Create a popper for positioning the tooltip content relative to refElem.
|
||||||
@ -86,11 +106,83 @@ export function showTooltip(
|
|||||||
return ctl;
|
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', `
|
const cssTooltip = styled('div', `
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5000; /* should be higher than a modal */
|
z-index: 5000; /* should be higher than a modal */
|
||||||
background-color: black;
|
background-color: rgba(0, 0, 0, 0.75);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
box-shadow: 0 0 2px rgba(0,0,0,0.5);
|
box-shadow: 0 0 2px rgba(0,0,0,0.5);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -100,6 +192,22 @@ const cssTooltip = styled('div', `
|
|||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
opacity: 0.75;
|
|
||||||
transition: opacity 0.2s;
|
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;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
@ -10,6 +10,7 @@ import { colors, cssHideForNarrowScreen, mediaNotSmall, testId } from 'app/clien
|
|||||||
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 { UserOverride } from 'app/common/DocListAPI';
|
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';
|
||||||
|
|
||||||
@ -102,7 +103,6 @@ export function docBreadcrumbs(
|
|||||||
docNameSave: (val: string) => Promise<void>,
|
docNameSave: (val: string) => Promise<void>,
|
||||||
pageNameSave: (val: string) => Promise<void>,
|
pageNameSave: (val: string) => Promise<void>,
|
||||||
cancelRecoveryMode: () => Promise<void>,
|
cancelRecoveryMode: () => Promise<void>,
|
||||||
cancelUserOverride: () => Promise<void>,
|
|
||||||
isDocNameReadOnly?: BindableValue<boolean>,
|
isDocNameReadOnly?: BindableValue<boolean>,
|
||||||
isPageNameReadOnly?: BindableValue<boolean>,
|
isPageNameReadOnly?: BindableValue<boolean>,
|
||||||
isFork: Observable<boolean>,
|
isFork: Observable<boolean>,
|
||||||
@ -154,9 +154,12 @@ export function docBreadcrumbs(
|
|||||||
const userOverride = use(options.userOverride);
|
const userOverride = use(options.userOverride);
|
||||||
if (userOverride) {
|
if (userOverride) {
|
||||||
return cssAlertTag(userOverride.user?.email || 'override',
|
return cssAlertTag(userOverride.user?.email || 'override',
|
||||||
dom('a', dom.on('click', () => options.cancelUserOverride()),
|
dom('a',
|
||||||
icon('CrossSmall')),
|
urlState().setHref(userOverrideParams(null)),
|
||||||
testId('user-override-tag'));
|
icon('CrossSmall')
|
||||||
|
),
|
||||||
|
testId('user-override-tag')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (use(options.isFiddle)) {
|
if (use(options.isFiddle)) {
|
||||||
return cssTag('fiddle', tooltip({title: fiddleExplanation}), testId('fiddle-tag'));
|
return cssTag('fiddle', tooltip({title: fiddleExplanation}), testId('fiddle-tag'));
|
||||||
|
@ -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 {colors, testId} 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 {cssLink} from 'app/client/ui2018/links';
|
||||||
@ -11,7 +11,7 @@ export function showTooltipToCreateFormula(editorDom: HTMLElement, convert: () =
|
|||||||
dom.on('mousedown', (ev) => { ev.preventDefault(); convert(); }),
|
dom.on('mousedown', (ev) => { ev.preventDefault(); convert(); }),
|
||||||
testId('editor-tooltip-convert'),
|
testId('editor-tooltip-convert'),
|
||||||
),
|
),
|
||||||
cssCloseButton(icon('CrossSmall'), dom.on('click', ctl.close)),
|
tooltipCloseButton(ctl),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const offerCtl = showTooltip(editorDom, buildTooltip, {key: 'col-to-formula'});
|
const offerCtl = showTooltip(editorDom, buildTooltip, {key: 'col-to-formula'});
|
||||||
@ -32,20 +32,3 @@ const cssConvertTooltip = styled('div', `
|
|||||||
margin-left: 8px;
|
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;
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
@ -297,6 +297,23 @@ export function useNewUI(newui: boolean|undefined) {
|
|||||||
return newui !== false;
|
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
|
* parseDocPage is a noop if p is 'new' or 'code', otherwise parse to integer
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user