mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +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 {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'), | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @ -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.
 | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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}; | ||||
|   } | ||||
| `);
 | ||||
|  | ||||
| @ -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), | ||||
|  | ||||
| @ -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; | ||||
|   } | ||||
| `);
 | ||||
|  | ||||
| @ -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')); | ||||
|  | ||||
| @ -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; | ||||
|   } | ||||
| `);
 | ||||
|  | ||||
| @ -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 | ||||
|  */ | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user