Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ export const MenuColumnWrapper = styled.div<{ selected: boolean }>`

export const ActionWrapper = styled.div<{ disabled: boolean }>`
margin: 0 5px 0 0;
max-width: 100%;
${(props) => (props.disabled ? "cursor: not-allowed;" : null)}
&&&&&& {
.bp3-button {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,45 +36,71 @@ const MAX_WIDTH = 500;
const TOOLTIP_OPEN_DELAY = 500;
const MAX_CHARS_ALLOWED_IN_TOOLTIP = 200;

function useToolTip(children: React.ReactNode, title?: string) {
export function isButtonTextTruncated(element: HTMLElement): boolean {
const spanElement = element.querySelector("span");

if (!spanElement) {
return false;
}

const offsetWidth = spanElement.offsetWidth;
const scrollWidth = spanElement.scrollWidth;

return scrollWidth > offsetWidth;
}

function useToolTip(
children: React.ReactNode,
title?: string,
isButton?: boolean,
) {
const ref = createRef<HTMLDivElement>();
const [requiresTooltip, setRequiresTooltip] = useState(false);

useEffect(() => {
let timeout: ReturnType<typeof setTimeout>;

const mouseEnterHandler = () => {
const element = ref.current?.querySelector("div") as HTMLDivElement;

/*
* Using setTimeout to simulate hoverOpenDelay of the tooltip
* during initial render
*/
timeout = setTimeout(() => {
if (element && element.offsetWidth < element.scrollWidth) {
setRequiresTooltip(true);
} else {
setRequiresTooltip(false);
}

ref.current?.removeEventListener("mouseenter", mouseEnterHandler);
ref.current?.removeEventListener("mouseleave", mouseLeaveHandler);
}, TOOLTIP_OPEN_DELAY);
};

const mouseLeaveHandler = () => {
clearTimeout(timeout);
};

ref.current?.addEventListener("mouseenter", mouseEnterHandler);
ref.current?.addEventListener("mouseleave", mouseLeaveHandler);

return () => {
ref.current?.removeEventListener("mouseenter", mouseEnterHandler);
ref.current?.removeEventListener("mouseleave", mouseLeaveHandler);
clearTimeout(timeout);
};
}, [children]);
useEffect(
function setupMouseHandlers() {
let timeout: ReturnType<typeof setTimeout>;
const currentRef = ref.current;

if (!currentRef) return;

const mouseEnterHandler = () => {
timeout = setTimeout(() => {
const element = currentRef?.querySelector("div") as HTMLDivElement;

/*
* Using setTimeout to simulate hoverOpenDelay of the tooltip
* during initial render
*/
if (element && element.offsetWidth < element.scrollWidth) {
setRequiresTooltip(true);
} else if (isButton && element && isButtonTextTruncated(element)) {
setRequiresTooltip(true);
} else {
setRequiresTooltip(false);
}

currentRef?.removeEventListener("mouseenter", mouseEnterHandler);
currentRef?.removeEventListener("mouseleave", mouseLeaveHandler);
}, TOOLTIP_OPEN_DELAY);
};

const mouseLeaveHandler = () => {
setRequiresTooltip(false);
clearTimeout(timeout);
};

currentRef?.addEventListener("mouseenter", mouseEnterHandler);
currentRef?.addEventListener("mouseleave", mouseLeaveHandler);

return () => {
currentRef?.removeEventListener("mouseenter", mouseEnterHandler);
currentRef?.removeEventListener("mouseleave", mouseLeaveHandler);
clearTimeout(timeout);
};
},
[children, isButton, ref],
);

return requiresTooltip && children ? (
<Tooltip
Expand Down Expand Up @@ -158,13 +184,21 @@ function LinkWrapper(props: Props) {
);
}

function AutoToolTipComponent(props: Props) {
const content = useToolTip(props.children, props.title);
export function AutoToolTipComponent(props: Props) {
const content = useToolTip(
props.children,
props.title,
props.columnType === ColumnTypes.BUTTON,
);

if (props.columnType === ColumnTypes.URL && props.title) {
return <LinkWrapper {...props} />;
}

if (props.columnType === ColumnTypes.BUTTON && props.title) {
return content;
}

return (
<ColumnWrapper className={props.className} textColor={props.textColor}>
<CellWrapper
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import AutoToolTipComponent from "./AutoToolTipComponent";
import { ColumnTypes } from "widgets/TableWidgetV2/constants";
import "@testing-library/jest-dom";
import { isButtonTextTruncated } from "./AutoToolTipComponent";

jest.mock("react", () => {
const actualReact = jest.requireActual("react");

return {
...actualReact,
useState: jest.fn((initial) => [initial, jest.fn()]),
};
});

test.each([
["truncated text", "This is a long text that will be truncated"],
[
"truncated button text",
"This is a long text that will be truncated in the button",
],
])("shows tooltip for %s", (_, longText) => {
const { getByText } = render(
<AutoToolTipComponent columnType={ColumnTypes.BUTTON} title={longText}>
<span
style={{
width: "50px",
display: "inline-block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{longText}
</span>
</AutoToolTipComponent>,
);

fireEvent.mouseEnter(getByText(longText));
expect(getByText(longText)).toBeInTheDocument();
});

test("does not show tooltip for non-button types", () => {
const { getByText } = render(
<AutoToolTipComponent columnType={ColumnTypes.URL} title="Not a button">
<a href="#">Not a button</a>
</AutoToolTipComponent>,
);

expect(getByText("Not a button")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

test("handles empty tooltip", () => {
const { getByText } = render(
<AutoToolTipComponent columnType={ColumnTypes.BUTTON} title="">
<button>Empty button</button>
</AutoToolTipComponent>,
);

expect(getByText("Empty button")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

test("renders content without tooltip for normal text", () => {
const { getByText } = render(
<AutoToolTipComponent title="Normal Text">
<span>Normal Text</span>
</AutoToolTipComponent>,
);

expect(getByText("Normal Text")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

test("does not show tooltip for non-truncated text", () => {
const shortText = "Short text";
const { getByText } = render(
<AutoToolTipComponent columnType={ColumnTypes.BUTTON} title={shortText}>
<span>{shortText}</span>
</AutoToolTipComponent>,
);

fireEvent.mouseEnter(getByText(shortText));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

test("opens a new tab for URL column type when clicked", () => {
const openSpy = jest.spyOn(window, "open").mockImplementation(() => null);

render(
<AutoToolTipComponent
columnType={ColumnTypes.URL}
title="Go to Google"
url="https://www.google.com"
>
<span>Go to Google</span>
</AutoToolTipComponent>,
);

fireEvent.click(screen.getByText("Go to Google"));
expect(openSpy).toHaveBeenCalledWith("https://www.google.com", "_blank");

openSpy.mockRestore();
});

describe("isButtonTextTruncated", () => {
function mockElementWidths(
offsetWidth: number,
scrollWidth: number,
): HTMLElement {
const spanElement = document.createElement("span");

Object.defineProperty(spanElement, "offsetWidth", { value: offsetWidth });
Object.defineProperty(spanElement, "scrollWidth", { value: scrollWidth });
const container = document.createElement("div");

container.appendChild(spanElement);

return container;
}

test("returns true when text is truncated (scrollWidth > offsetWidth)", () => {
const element = mockElementWidths(100, 150);

expect(isButtonTextTruncated(element)).toBe(true);
});

test("returns false when text is not truncated (scrollWidth <= offsetWidth)", () => {
const element = mockElementWidths(150, 150);

expect(isButtonTextTruncated(element)).toBe(false);
});

test("returns false when no span element is found", () => {
const element = document.createElement("div");

expect(isButtonTextTruncated(element)).toBe(false);
});
});
Comment on lines +108 to +141
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Well done on testing the utility function! Here's some extra credit work 🌟

Your tests for isButtonTextTruncated show excellent attention to detail. For extra points, consider adding these additional test cases:

test("handles negative width values gracefully", () => {
  const element = mockElementWidths(-1, 150);
  expect(isButtonTextTruncated(element)).toBe(false);
});

test("handles zero width values", () => {
  const element = mockElementWidths(0, 0);
  expect(isButtonTextTruncated(element)).toBe(false);
});

Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ import React, { useState } from "react";

import { ActionWrapper } from "../TableStyledWrappers";
import { BaseButton } from "widgets/ButtonWidget/component";
import type { ButtonColumnActions } from "widgets/TableWidgetV2/constants";
import {
ColumnTypes,
type ButtonColumnActions,
} from "widgets/TableWidgetV2/constants";
import styled from "styled-components";
import AutoToolTipComponent from "widgets/TableWidgetV2/component/cellComponents/AutoToolTipComponent";

const StyledButton = styled(BaseButton)<{
compactMode?: string;
Expand Down Expand Up @@ -37,27 +41,31 @@ export function Button(props: ButtonProps) {
props.onCommandClick(props.action.dynamicTrigger, onComplete);
};

const stopPropagation = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
};

return (
<ActionWrapper
disabled={!!props.isDisabled}
onClick={(e) => {
e.stopPropagation();
}}
>
{props.isCellVisible && props.action.isVisible ? (
<StyledButton
borderRadius={props.action.borderRadius}
boxShadow={props.action.boxShadow}
buttonColor={props.action.backgroundColor}
buttonVariant={props.action.variant}
compactMode={props.compactMode}
disabled={props.isDisabled}
iconAlign={props.action.iconAlign}
iconName={props.action.iconName}
loading={loading}
onClick={handleClick}
text={props.action.label}
/>
<ActionWrapper disabled={!!props.isDisabled} onClick={stopPropagation}>
{props.isCellVisible && props.action.isVisible && props.action.label ? (
<AutoToolTipComponent
columnType={ColumnTypes.BUTTON}
title={props.action.label}
>
<StyledButton
borderRadius={props.action.borderRadius}
boxShadow={props.action.boxShadow}
buttonColor={props.action.backgroundColor}
buttonVariant={props.action.variant}
compactMode={props.compactMode}
disabled={props.isDisabled}
iconAlign={props.action.iconAlign}
iconName={props.action.iconName}
loading={loading}
onClick={handleClick}
text={props.action.label}
/>
</AutoToolTipComponent>
) : null}
</ActionWrapper>
);
Expand Down