1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Rewrite withTooltip to be more type correct

- Fixes mobx related "too many recursive actions" error

- Change all the uses of withTooltips to be functional components

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-04-25 18:16:19 -04:00
parent 413fa8a815
commit d42d2eb0c4
9 changed files with 222 additions and 261 deletions

View File

@ -5,15 +5,13 @@
import styles from "./badge.module.scss"; import styles from "./badge.module.scss";
import React from "react"; import React, { createRef, useState } from "react";
import { computed, makeObservable, observable } from "mobx"; import { action, observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { cssNames } from "../../utils/cssNames"; import { cssNames } from "../../utils/cssNames";
import type { TooltipDecoratorProps } from "../tooltip";
import { withTooltip } from "../tooltip"; import { withTooltip } from "../tooltip";
import { autoBind } from "../../utils";
export interface BadgeProps extends React.HTMLAttributes<any>, TooltipDecoratorProps { export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
small?: boolean; small?: boolean;
flat?: boolean; flat?: boolean;
label?: React.ReactNode; label?: React.ReactNode;
@ -24,66 +22,56 @@ export interface BadgeProps extends React.HTMLAttributes<any>, TooltipDecoratorP
// Common handler for all Badge instances // Common handler for all Badge instances
document.addEventListener("selectionchange", () => { document.addEventListener("selectionchange", () => {
Badge.badgeMeta.hasTextSelected ||= (window.getSelection()?.toString().trim().length ?? 0) > 0; badgeMeta.hasTextSelected ||= (window.getSelection()?.toString().trim().length ?? 0) > 0;
}); });
@withTooltip const badgeMeta = observable({
@observer hasTextSelected: false,
export class Badge extends React.Component<BadgeProps> { });
static defaultProps: Partial<BadgeProps> = {
expandable: true,
};
static badgeMeta = observable({ export const Badge = withTooltip(observer(({
hasTextSelected: false, small,
flat,
label,
expandable = false,
disabled,
scrollable,
className,
children,
...elemProps
}: BadgeProps) => {
const elem = createRef<HTMLDivElement>();
const [isExpanded, setIsExpanded] = useState(false);
const isExpandable = expandable && elem.current
? elem.current.clientWidth < elem.current.scrollWidth
: false;
const onMouseUp = action(() => {
if (!isExpandable || badgeMeta.hasTextSelected) {
badgeMeta.hasTextSelected = false;
} else {
setIsExpanded(!isExpanded);
}
}); });
@observable.ref elem: HTMLDivElement | null = null; return (
@observable isExpanded = false; <div
{...elemProps}
constructor(props: BadgeProps) { className={cssNames(styles.badge, className, {
super(props); [styles.small]: small,
makeObservable(this); [styles.flat]: flat,
autoBind(this); [styles.clickable]: Boolean(elemProps.onClick) || isExpandable,
} [styles.interactive]: isExpandable,
[styles.isExpanded]: isExpanded,
@computed get isExpandable() { [styles.disabled]: disabled,
return this.props.expandable && this.elem [styles.scrollable]: scrollable,
? this.elem.clientWidth < this.elem.scrollWidth })}
: false; onMouseUp={onMouseUp}
} ref={elem}
>
onMouseUp() { {label}
if (!this.isExpandable || Badge.badgeMeta.hasTextSelected) { {children}
Badge.badgeMeta.hasTextSelected = false; </div>
} else { );
this.isExpanded = !this.isExpanded; }));
}
}
render() {
const { className, label, disabled, scrollable, small, children, flat, expandable, ...elemProps } = this.props;
const clickable = Boolean(this.props.onClick) || this.isExpandable;
const classNames = cssNames(styles.badge, className, {
[styles.small]: small,
[styles.flat]: flat,
[styles.clickable]: clickable,
[styles.interactive]: this.isExpandable,
[styles.isExpanded]: this.isExpanded,
[styles.disabled]: disabled,
[styles.scrollable]: scrollable,
});
return (
<div
{...elemProps}
className={classNames}
onMouseUp={this.onMouseUp}
ref={elem => this.elem = elem}
>
{label}
{children}
</div>
);
}
}

View File

@ -7,10 +7,9 @@ import "./button.scss";
import type { ButtonHTMLAttributes } from "react"; import type { ButtonHTMLAttributes } from "react";
import React from "react"; import React from "react";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import type { TooltipDecoratorProps } from "../tooltip";
import { withTooltip } from "../tooltip"; import { withTooltip } from "../tooltip";
export interface ButtonProps extends ButtonHTMLAttributes<any>, TooltipDecoratorProps { export interface ButtonProps extends ButtonHTMLAttributes<any> {
label?: React.ReactNode; label?: React.ReactNode;
waiting?: boolean; waiting?: boolean;
primary?: boolean; primary?: boolean;
@ -26,36 +25,33 @@ export interface ButtonProps extends ButtonHTMLAttributes<any>, TooltipDecorator
target?: "_blank"; // in case of using @href target?: "_blank"; // in case of using @href
} }
@withTooltip export const Button = withTooltip((props: ButtonProps) => {
export class Button extends React.PureComponent<ButtonProps, {}> { const {
render() { waiting, label, primary, accent, plain, hidden, active, big,
const { round, outlined, light, children, ...btnProps
waiting, label, primary, accent, plain, hidden, active, big, } = props;
round, outlined, tooltip, light, children, tooltipOverrideDisabled, ...btnProps
} = this.props;
if (hidden) return null; if (hidden) return null;
btnProps.className = cssNames("Button", btnProps.className, { btnProps.className = cssNames("Button", btnProps.className, {
waiting, primary, accent, plain, active, big, round, outlined, light, waiting, primary, accent, plain, active, big, round, outlined, light,
}); });
// render as link // render as link
if (this.props.href) { if (props.href) {
return (
<a {...btnProps}>
{label}
{children}
</a>
);
}
// render as button
return ( return (
<button type="button" {...btnProps}> <a {...btnProps}>
{label} {label}
{children} {children}
</button> </a>
); );
} }
}
// render as button
return (
<button type="button" {...btnProps}>
{label}
{children}
</button>
);
});

View File

@ -18,7 +18,7 @@ import * as resourceApplierApi from "../../../../common/k8s-api/endpoints/resour
import { Notifications } from "../../notifications"; import { Notifications } from "../../notifications";
import logger from "../../../../common/logger"; import logger from "../../../../common/logger";
import type { ApiManager } from "../../../../common/k8s-api/api-manager"; import type { ApiManager } from "../../../../common/k8s-api/api-manager";
import { isString, prevDefault } from "../../../utils"; import { isObject, prevDefault } from "../../../utils";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import createResourceTabStoreInjectable from "./store.injectable"; import createResourceTabStoreInjectable from "./store.injectable";
import createResourceTemplatesInjectable from "./create-resource-templates.injectable"; import createResourceTemplatesInjectable from "./create-resource-templates.injectable";
@ -77,7 +77,7 @@ class NonInjectedCreateResource extends React.Component<CreateResourceProps & De
} }
// skip empty documents // skip empty documents
const resources = yaml.loadAll(this.data).filter(isString); const resources = yaml.loadAll(this.data).filter(isObject);
if (resources.length === 0) { if (resources.length === 0) {
return void logger.info("Nothing to create"); return void logger.info("Nothing to create");

View File

@ -5,11 +5,11 @@
import styles from "./editor-panel.module.scss"; import styles from "./editor-panel.module.scss";
import throttle from "lodash/throttle"; import throttle from "lodash/throttle";
import React from "react"; import React, { createRef, useEffect } from "react";
import { makeObservable, reaction } from "mobx"; import { reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { observer } from "mobx-react";
import type { DockStore, TabId } from "./dock/store"; import type { DockStore, TabId } from "./dock/store";
import { cssNames } from "../../utils"; import { cssNames, disposer } from "../../utils";
import { MonacoEditor } from "../monaco-editor"; import { MonacoEditor } from "../monaco-editor";
import type { MonacoEditorProps, MonacoEditorRef } from "../monaco-editor"; import type { MonacoEditorProps, MonacoEditorRef } from "../monaco-editor";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
@ -28,51 +28,44 @@ interface Dependencies {
dockStore: DockStore; dockStore: DockStore;
} }
const defaultProps: Partial<EditorPanelProps> = { const NonInjectedEditorPanel = observer(({
autoFocus: true, dockStore,
}; onChange,
tabId,
value,
autoFocus = true,
className,
onError,
}: Dependencies & EditorPanelProps) => {
const editor = createRef<MonacoEditorRef>();
@observer useEffect(() => disposer(
class NonInjectedEditorPanel extends React.Component<EditorPanelProps & Dependencies> { reaction(
static defaultProps = defaultProps as object; () => dockStore.isOpen,
isOpen => isOpen && editor.current?.focus(),
private readonly editor = React.createRef<MonacoEditorRef>(); {
constructor(props: EditorPanelProps & Dependencies) {
super(props);
makeObservable(this);
}
componentDidMount() {
disposeOnUnmount(this, [
// keep focus on editor's area when <Dock/> just opened
reaction(() => this.props.dockStore.isOpen, isOpen => isOpen && this.editor.current?.focus(), {
fireImmediately: true, fireImmediately: true,
}), },
),
dockStore.onResize(throttle(() => editor.current?.focus(), 250)),
));
// focus to editor on dock's resize or turning into fullscreen mode if (!tabId) {
this.props.dockStore.onResize(throttle(() => this.editor.current?.focus(), 250)), return null;
]);
} }
render() { return (
const { className, autoFocus, tabId, value, onChange, onError } = this.props; <MonacoEditor
autoFocus={autoFocus}
if (!tabId) return null; id={tabId}
value={value}
return ( className={cssNames(styles.EditorPanel, className)}
<MonacoEditor onChange={onChange}
autoFocus={autoFocus} onError={onError}
id={tabId} ref={editor}
value={value} />
className={cssNames(styles.EditorPanel, className)} );
onChange={onChange} });
onError={onError}
ref={this.editor}
/>
);
}
}
export const EditorPanel = withInjectables<Dependencies, EditorPanelProps>(NonInjectedEditorPanel, { export const EditorPanel = withInjectables<Dependencies, EditorPanelProps>(NonInjectedEditorPanel, {
getProps: (di, props) => ({ getProps: (di, props) => ({

View File

@ -9,13 +9,12 @@ import type { ReactNode } from "react";
import React, { createRef } from "react"; import React, { createRef } from "react";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import type { LocationDescriptor } from "history"; import type { LocationDescriptor } from "history";
import { autoBind, cssNames } from "../../utils"; import { cssNames } from "../../utils";
import type { TooltipDecoratorProps } from "../tooltip";
import { withTooltip } from "../tooltip"; import { withTooltip } from "../tooltip";
import isNumber from "lodash/isNumber"; import isNumber from "lodash/isNumber";
import { decode } from "../../../common/utils/base64"; import { decode } from "../../../common/utils/base64";
export interface IconProps extends React.HTMLAttributes<any>, TooltipDecoratorProps { export interface IconProps extends React.HTMLAttributes<any> {
material?: string; // material-icon, see available names at https://material.io/icons/ material?: string; // material-icon, see available names at https://material.io/icons/
svg?: string; // svg-filename without extension in current folder svg?: string; // svg-filename without extension in current folder
link?: LocationDescriptor; // render icon as NavLink from react-router-dom link?: LocationDescriptor; // render icon as NavLink from react-router-dom
@ -31,67 +30,38 @@ export interface IconProps extends React.HTMLAttributes<any>, TooltipDecoratorPr
disabled?: boolean; disabled?: boolean;
} }
@withTooltip export const Icon = Object.assign(
export class Icon extends React.PureComponent<IconProps> { withTooltip((props: IconProps) => {
private readonly ref = createRef<HTMLAnchorElement>(); const ref = createRef<HTMLAnchorElement>();
static defaultProps: IconProps = {
focusable: true,
};
static isSvg(content: string) {
return String(content).includes("svg+xml"); // data-url for raw svg-icon
}
constructor(props: IconProps) {
super(props);
autoBind(this);
}
get isInteractive() {
const { interactive, onClick, href, link } = this.props;
return interactive ?? !!(onClick || href || link);
}
onClick(evt: React.MouseEvent) {
if (this.props.disabled) {
return;
}
if (this.props.onClick) {
this.props.onClick(evt);
}
}
onKeyDown(evt: React.KeyboardEvent<any>) {
switch (evt.nativeEvent.code) {
case "Space":
// fallthrough
case "Enter": {
this.ref.current?.click();
evt.preventDefault();
break;
}
}
if (this.props.onKeyDown) {
this.props.onKeyDown(evt);
}
}
render() {
const { isInteractive } = this;
const { const {
// skip passing props to icon's html element // skip passing props to icon's html element
className, href, link, material, svg, size, smallest, small, big, className, href, link, material, svg, size, smallest, small, big,
disabled, sticker, active, focusable, children, disabled, sticker, active, focusable, children,
interactive: _interactive, interactive, onClick, onKeyDown,
onClick: _onClick,
onKeyDown: _onKeyDown,
...elemProps ...elemProps
} = this.props; } = props;
const isInteractive = interactive ?? !!(onClick || href || link);
const boundOnClick = (event: React.MouseEvent) => {
if (!disabled) {
onClick?.(event);
}
};
const boundOnKeyDown = (event: React.KeyboardEvent<any>) => {
switch (event.nativeEvent.code) {
case "Space":
// fallthrough
case "Enter": {
ref.current?.click();
event.preventDefault();
break;
}
}
onKeyDown?.(event);
};
let iconContent: ReactNode; let iconContent: ReactNode;
const iconProps: Partial<IconProps> = { const iconProps: Partial<IconProps> = {
@ -99,8 +69,8 @@ export class Icon extends React.PureComponent<IconProps> {
{ svg, material, interactive: isInteractive, disabled, sticker, active, focusable }, { svg, material, interactive: isInteractive, disabled, sticker, active, focusable },
!size ? { smallest, small, big } : {}, !size ? { smallest, small, big } : {},
), ),
onClick: isInteractive ? this.onClick : undefined, onClick: isInteractive ? boundOnClick : undefined,
onKeyDown: isInteractive ? this.onKeyDown : undefined, onKeyDown: isInteractive ? boundOnKeyDown : undefined,
tabIndex: isInteractive && focusable && !disabled ? 0 : undefined, tabIndex: isInteractive && focusable && !disabled ? 0 : undefined,
style: size ? { "--size": size + (isNumber(size) ? "px" : "") } as React.CSSProperties : undefined, style: size ? { "--size": size + (isNumber(size) ? "px" : "") } as React.CSSProperties : undefined,
...elemProps, ...elemProps,
@ -137,7 +107,7 @@ export class Icon extends React.PureComponent<IconProps> {
<NavLink <NavLink
className={className} className={className}
to={link} to={link}
ref={this.ref} ref={ref}
> >
{children} {children}
</NavLink> </NavLink>
@ -149,11 +119,15 @@ export class Icon extends React.PureComponent<IconProps> {
<a <a
{...iconProps} {...iconProps}
href={href} href={href}
ref={this.ref} ref={ref}
/> />
); );
} }
return <i {...iconProps} ref={this.ref} />; return <i {...iconProps} ref={ref} />;
} }),
} {
// data-url for raw svg-icon
isSvg: (content: string) => String(content).includes("svg+xml"),
},
);

View File

@ -6,10 +6,9 @@
import "./line-progress.scss"; import "./line-progress.scss";
import React from "react"; import React from "react";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import type { TooltipDecoratorProps } from "../tooltip";
import { withTooltip } from "../tooltip"; import { withTooltip } from "../tooltip";
export interface LineProgressProps extends React.HTMLProps<any>, TooltipDecoratorProps { export interface LineProgressProps extends React.HTMLProps<HTMLDivElement> {
value: number; value: number;
min?: number; min?: number;
max?: number; max?: number;
@ -17,6 +16,10 @@ export interface LineProgressProps extends React.HTMLProps<any>, TooltipDecorato
precise?: number; precise?: number;
} }
function valuePercent({ value, min, max, precise }: Required<Pick<LineProgressProps, "value" | "min" | "max" | "precise">>) {
return Math.min(100, value / (max - min) * 100).toFixed(precise);
}
export const LineProgress = withTooltip(({ export const LineProgress = withTooltip(({
className, className,
min = 0, min = 0,
@ -25,13 +28,14 @@ export const LineProgress = withTooltip(({
precise = 2, precise = 2,
children, children,
...props ...props
}: LineProgressProps) => { }: LineProgressProps) => (
const valuePercents = Math.min(100, value / (max - min) * 100).toFixed(precise); <div className={cssNames("LineProgress", className)} {...props}>
<div
return ( className="line"
<div className={cssNames("LineProgress", className)} {...props}> style={{
<div className="line" style={{ width: `${valuePercents}%` }}></div> width: `${valuePercent({ min, max, value, precise })}%`,
{children} }}
</div> />
); {children}
}); </div>
));

View File

@ -16,12 +16,13 @@ import type { MenuProps } from "./menu";
import { Menu, MenuItem } from "./menu"; import { Menu, MenuItem } from "./menu";
import uniqueId from "lodash/uniqueId"; import uniqueId from "lodash/uniqueId";
import isString from "lodash/isString"; import isString from "lodash/isString";
import type { TooltipDecoratorProps } from "../tooltip";
export interface MenuActionsProps extends Partial<MenuProps> { export interface MenuActionsProps extends Partial<MenuProps> {
className?: string; className?: string;
toolbar?: boolean; // display menu as toolbar with icons toolbar?: boolean; // display menu as toolbar with icons
autoCloseOnSelect?: boolean; autoCloseOnSelect?: boolean;
triggerIcon?: string | IconProps | React.ReactNode; triggerIcon?: string | (IconProps & TooltipDecoratorProps) | React.ReactNode;
removeConfirmationMessage?: React.ReactNode | (() => React.ReactNode); removeConfirmationMessage?: React.ReactNode | (() => React.ReactNode);
updateAction?: () => void | Promise<void>; updateAction?: () => void | Promise<void>;
removeAction?: () => void | Promise<void>; removeAction?: () => void | Promise<void>;
@ -75,7 +76,7 @@ export class MenuActions extends React.Component<MenuActionsProps> {
return React.cloneElement(triggerIcon, { id: this.id, className }); return React.cloneElement(triggerIcon, { id: this.id, className });
} }
const iconProps: Partial<IconProps> = { const iconProps: IconProps & TooltipDecoratorProps = {
id: this.id, id: this.id,
interactive: true, interactive: true,
material: isString(triggerIcon) ? triggerIcon : undefined, material: isString(triggerIcon) ? triggerIcon : undefined,

View File

@ -7,22 +7,13 @@ import "./status-brick.scss";
import React from "react"; import React from "react";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import type { TooltipDecoratorProps } from "../tooltip";
import { withTooltip } from "../tooltip"; import { withTooltip } from "../tooltip";
export interface StatusBrickProps extends React.HTMLAttributes<any>, TooltipDecoratorProps { export interface StatusBrickProps extends React.HTMLAttributes<any> {
} }
@withTooltip export const StatusBrick = withTooltip(({ className, ...elemProps }: StatusBrickProps) => (
export class StatusBrick extends React.Component<StatusBrickProps> { <div
render() { className={cssNames("StatusBrick", className)}
const { className, ...elemProps } = this.props; {...elemProps} />
));
return (
<div
className={cssNames("StatusBrick", className)}
{...elemProps}
/>
);
}
}

View File

@ -5,13 +5,14 @@
// Tooltip decorator for simple composition with other components // Tooltip decorator for simple composition with other components
import type { HTMLAttributes, ReactNode } from "react"; import type { ReactNode } from "react";
import React from "react"; import React, { useState } from "react";
import hoistNonReactStatics from "hoist-non-react-statics"; import hoistNonReactStatics from "hoist-non-react-statics";
import type { TooltipProps } from "./tooltip"; import type { TooltipProps } from "./tooltip";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
import { isReactNode } from "../../utils/isReactNode"; import { isReactNode } from "../../utils/isReactNode";
import uniqueId from "lodash/uniqueId"; import uniqueId from "lodash/uniqueId";
import type { SingleOrMany } from "../../utils";
export interface TooltipDecoratorProps { export interface TooltipDecoratorProps {
tooltip?: ReactNode | Omit<TooltipProps, "targetId">; tooltip?: ReactNode | Omit<TooltipProps, "targetId">;
@ -20,41 +21,54 @@ export interface TooltipDecoratorProps {
* useful for displaying tooltips even when the target is "disabled" * useful for displaying tooltips even when the target is "disabled"
*/ */
tooltipOverrideDisabled?: boolean; tooltipOverrideDisabled?: boolean;
id?: string | undefined;
children?: SingleOrMany<React.ReactNode>;
} }
export function withTooltip<T extends React.ComponentType<any>>(Target: T): T { export function withTooltip<TargetProps extends Pick<TooltipDecoratorProps, "id" | "children">>(Target: React.FunctionComponent<TargetProps>): React.FunctionComponent<TargetProps & TooltipDecoratorProps> {
const DecoratedComponent = class extends React.Component<HTMLAttributes<any> & TooltipDecoratorProps> { const DecoratedComponent = (props: TargetProps & TooltipDecoratorProps) => {
static displayName = `withTooltip(${Target.displayName || Target.name})`;
// TODO: Remove side-effect to allow deterministic unit testing // TODO: Remove side-effect to allow deterministic unit testing
protected tooltipId = uniqueId("tooltip_target_"); const [defaultTooltipId] = useState(uniqueId("tooltip_target_"));
render() { let {
const { tooltip, tooltipOverrideDisabled, ...targetProps } = this.props; id: targetId,
children: targetChildren,
} = props;
const {
tooltip,
tooltipOverrideDisabled,
id: _unusedId,
children: _unusedTargetChildren,
...targetProps
} = props;
if (tooltip) { if (tooltip) {
const tooltipId = targetProps.id || this.tooltipId; const tooltipProps: TooltipProps = {
const tooltipProps: TooltipProps = { targetId: targetId || defaultTooltipId,
targetId: tooltipId, tooltipOnParentHover: tooltipOverrideDisabled,
tooltipOnParentHover: tooltipOverrideDisabled, formatters: { narrow: true },
formatters: { narrow: true }, ...(isReactNode(tooltip) ? { children: tooltip } : tooltip),
...(isReactNode(tooltip) ? { children: tooltip } : tooltip), };
};
targetProps.id = tooltipId; targetId = tooltipProps.targetId;
targetProps.children = ( targetChildren = (
<> <>
<div> <div>
{targetProps.children} {targetChildren}
</div> </div>
<Tooltip {...tooltipProps} /> <Tooltip {...tooltipProps} />
</> </>
); );
}
return <Target {...targetProps as any} />;
} }
return (
<Target id={targetId} {...targetProps as TargetProps}>
{targetChildren}
</Target>
);
}; };
return hoistNonReactStatics(DecoratedComponent, Target) as any; DecoratedComponent.displayName = `withTooltip(${Target.displayName || Target.name})`;
return hoistNonReactStatics(DecoratedComponent, Target);
} }