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

View File

@ -7,10 +7,9 @@ import "./button.scss";
import type { ButtonHTMLAttributes } from "react";
import React from "react";
import { cssNames } from "../../utils";
import type { TooltipDecoratorProps } from "../tooltip";
import { withTooltip } from "../tooltip";
export interface ButtonProps extends ButtonHTMLAttributes<any>, TooltipDecoratorProps {
export interface ButtonProps extends ButtonHTMLAttributes<any> {
label?: React.ReactNode;
waiting?: boolean;
primary?: boolean;
@ -26,36 +25,33 @@ export interface ButtonProps extends ButtonHTMLAttributes<any>, TooltipDecorator
target?: "_blank"; // in case of using @href
}
@withTooltip
export class Button extends React.PureComponent<ButtonProps, {}> {
render() {
const {
waiting, label, primary, accent, plain, hidden, active, big,
round, outlined, tooltip, light, children, tooltipOverrideDisabled, ...btnProps
} = this.props;
export const Button = withTooltip((props: ButtonProps) => {
const {
waiting, label, primary, accent, plain, hidden, active, big,
round, outlined, light, children, ...btnProps
} = props;
if (hidden) return null;
if (hidden) return null;
btnProps.className = cssNames("Button", btnProps.className, {
waiting, primary, accent, plain, active, big, round, outlined, light,
});
btnProps.className = cssNames("Button", btnProps.className, {
waiting, primary, accent, plain, active, big, round, outlined, light,
});
// render as link
if (this.props.href) {
return (
<a {...btnProps}>
{label}
{children}
</a>
);
}
// render as button
// render as link
if (props.href) {
return (
<button type="button" {...btnProps}>
<a {...btnProps}>
{label}
{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 logger from "../../../../common/logger";
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 createResourceTabStoreInjectable from "./store.injectable";
import createResourceTemplatesInjectable from "./create-resource-templates.injectable";
@ -77,7 +77,7 @@ class NonInjectedCreateResource extends React.Component<CreateResourceProps & De
}
// skip empty documents
const resources = yaml.loadAll(this.data).filter(isString);
const resources = yaml.loadAll(this.data).filter(isObject);
if (resources.length === 0) {
return void logger.info("Nothing to create");

View File

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

View File

@ -9,13 +9,12 @@ import type { ReactNode } from "react";
import React, { createRef } from "react";
import { NavLink } from "react-router-dom";
import type { LocationDescriptor } from "history";
import { autoBind, cssNames } from "../../utils";
import type { TooltipDecoratorProps } from "../tooltip";
import { cssNames } from "../../utils";
import { withTooltip } from "../tooltip";
import isNumber from "lodash/isNumber";
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/
svg?: string; // svg-filename without extension in current folder
link?: LocationDescriptor; // render icon as NavLink from react-router-dom
@ -31,67 +30,38 @@ export interface IconProps extends React.HTMLAttributes<any>, TooltipDecoratorPr
disabled?: boolean;
}
@withTooltip
export class Icon extends React.PureComponent<IconProps> {
private readonly ref = createRef<HTMLAnchorElement>();
export const Icon = Object.assign(
withTooltip((props: IconProps) => {
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 {
// 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,
disabled, sticker, active, focusable, children,
interactive: _interactive,
onClick: _onClick,
onKeyDown: _onKeyDown,
interactive, onClick, onKeyDown,
...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;
const iconProps: Partial<IconProps> = {
@ -99,8 +69,8 @@ export class Icon extends React.PureComponent<IconProps> {
{ svg, material, interactive: isInteractive, disabled, sticker, active, focusable },
!size ? { smallest, small, big } : {},
),
onClick: isInteractive ? this.onClick : undefined,
onKeyDown: isInteractive ? this.onKeyDown : undefined,
onClick: isInteractive ? boundOnClick : undefined,
onKeyDown: isInteractive ? boundOnKeyDown : undefined,
tabIndex: isInteractive && focusable && !disabled ? 0 : undefined,
style: size ? { "--size": size + (isNumber(size) ? "px" : "") } as React.CSSProperties : undefined,
...elemProps,
@ -137,7 +107,7 @@ export class Icon extends React.PureComponent<IconProps> {
<NavLink
className={className}
to={link}
ref={this.ref}
ref={ref}
>
{children}
</NavLink>
@ -149,11 +119,15 @@ export class Icon extends React.PureComponent<IconProps> {
<a
{...iconProps}
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 React from "react";
import { cssNames } from "../../utils";
import type { TooltipDecoratorProps } from "../tooltip";
import { withTooltip } from "../tooltip";
export interface LineProgressProps extends React.HTMLProps<any>, TooltipDecoratorProps {
export interface LineProgressProps extends React.HTMLProps<HTMLDivElement> {
value: number;
min?: number;
max?: number;
@ -17,6 +16,10 @@ export interface LineProgressProps extends React.HTMLProps<any>, TooltipDecorato
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(({
className,
min = 0,
@ -25,13 +28,14 @@ export const LineProgress = withTooltip(({
precise = 2,
children,
...props
}: LineProgressProps) => {
const valuePercents = Math.min(100, value / (max - min) * 100).toFixed(precise);
return (
<div className={cssNames("LineProgress", className)} {...props}>
<div className="line" style={{ width: `${valuePercents}%` }}></div>
{children}
</div>
);
});
}: LineProgressProps) => (
<div className={cssNames("LineProgress", className)} {...props}>
<div
className="line"
style={{
width: `${valuePercent({ min, max, value, precise })}%`,
}}
/>
{children}
</div>
));

View File

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

View File

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

View File

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