From 3b353d6316d8b0d1b322f831d0466b6e42ac8b57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 11 Jun 2026 00:44:59 -0300 Subject: [PATCH 1/2] fix: add ellipsis tooltip component on mui tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- .../mui/__tests__/ellipsis-tooltip.test.js | 65 +++++++++++ .../mui/__tests__/mui-table.test.js | 13 +++ .../mui/editable-table/mui-table-editable.js | 5 + src/components/mui/ellipsis-tooltip.js | 24 ++++ .../mui/sortable-table/mui-table-sortable.js | 9 +- src/components/mui/table/mui-table.js | 103 ++++++++++-------- 6 files changed, 171 insertions(+), 48 deletions(-) create mode 100644 src/components/mui/__tests__/ellipsis-tooltip.test.js create mode 100644 src/components/mui/ellipsis-tooltip.js diff --git a/src/components/mui/__tests__/ellipsis-tooltip.test.js b/src/components/mui/__tests__/ellipsis-tooltip.test.js new file mode 100644 index 00000000..1b7d9a55 --- /dev/null +++ b/src/components/mui/__tests__/ellipsis-tooltip.test.js @@ -0,0 +1,65 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import EllipsisTooltip from "../ellipsis-tooltip"; + +describe("EllipsisTooltip", () => { + let scrollWidthSpy; + let offsetWidthSpy; + + beforeEach(() => { + scrollWidthSpy = jest.spyOn(Element.prototype, "scrollWidth", "get").mockReturnValue(0); + offsetWidthSpy = jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(0); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("renders children", () => { + render(content); + expect(screen.getByText("content")).toBeInTheDocument(); + }); + + test("wrapper span has truncation styles", () => { + render(content); + const wrapper = screen.getByText("content").parentElement; + expect(wrapper).toHaveStyle({ + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" + }); + }); + + test("shows tooltip on hover when content overflows", async () => { + scrollWidthSpy.mockReturnValue(200); + offsetWidthSpy.mockReturnValue(100); + + render(long content); + await userEvent.hover(screen.getByText("long content").parentElement); + + expect(await screen.findByRole("tooltip")).toHaveTextContent("overflow tip"); + }); + + test("does not show tooltip when content fits", async () => { + // scrollWidth (0) <= offsetWidth (0) → not overflowing + render(short); + await userEvent.hover(screen.getByText("short").parentElement); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/mui/__tests__/mui-table.test.js b/src/components/mui/__tests__/mui-table.test.js index 55435d0a..b4540ea9 100644 --- a/src/components/mui/__tests__/mui-table.test.js +++ b/src/components/mui/__tests__/mui-table.test.js @@ -265,4 +265,17 @@ describe("MuiTable", () => { // MUI CheckIcon renders an SVG; just ensure no error expect(screen.getByRole("cell", { hidden: true })).toBeInTheDocument(); }); + + describe("ellipsis column prop", () => { + test("wraps cell in truncating span when ellipsis is true", () => { + const cols = [{ columnKey: "name", header: "Name", ellipsis: true }]; + setup({ columns: cols }); + const wrapper = screen.getByText("Alice").parentElement; + expect(wrapper).toHaveStyle({ + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" + }); + }); + }); }); diff --git a/src/components/mui/editable-table/mui-table-editable.js b/src/components/mui/editable-table/mui-table-editable.js index 3a9e75c9..b4ca92a4 100644 --- a/src/components/mui/editable-table/mui-table-editable.js +++ b/src/components/mui/editable-table/mui-table-editable.js @@ -36,6 +36,7 @@ import { TWENTY_PER_PAGE } from "../../../utils/constants"; import showConfirmDialog from "../showConfirmDialog"; +import EllipsisTooltip from "../ellipsis-tooltip"; const ARCHIVED_CELL_SX = { backgroundColor: "background.light", @@ -317,6 +318,10 @@ const MuiTableEditable = ({ } validation={col.validation} /> + ) : col.ellipsis ? ( + + {col.render ? col.render(row) : {row[col.columnKey]}} + ) : col.render ? ( col.render(row) ) : ( diff --git a/src/components/mui/ellipsis-tooltip.js b/src/components/mui/ellipsis-tooltip.js new file mode 100644 index 00000000..6899ca94 --- /dev/null +++ b/src/components/mui/ellipsis-tooltip.js @@ -0,0 +1,24 @@ +import * as React from "react"; +import Tooltip from "@mui/material/Tooltip"; + +const EllipsisTooltip = ({ children, title }) => { + const ref = React.useRef(null); + const [isOverflowing, setIsOverflowing] = React.useState(false); + + return ( + + { + if (ref.current) + setIsOverflowing(ref.current.scrollWidth > ref.current.offsetWidth); + }} + style={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} + > + {children} + + + ); +}; + +export default EllipsisTooltip; diff --git a/src/components/mui/sortable-table/mui-table-sortable.js b/src/components/mui/sortable-table/mui-table-sortable.js index ade4f26d..2404550f 100644 --- a/src/components/mui/sortable-table/mui-table-sortable.js +++ b/src/components/mui/sortable-table/mui-table-sortable.js @@ -38,6 +38,7 @@ import { TWENTY_PER_PAGE } from "../../../utils/constants"; import showConfirmDialog from "../showConfirmDialog"; +import EllipsisTooltip from "../ellipsis-tooltip"; const MuiTableSortable = ({ columns = [], @@ -218,7 +219,13 @@ const MuiTableSortable = ({ } ${col.className}`} sx={{ fontWeight: "normal" }} > - {col.render?.(row) || row[col.columnKey]} + {col.ellipsis ? ( + + {col.render?.(row) || {row[col.columnKey]}} + + ) : ( + col.render?.(row) || row[col.columnKey] + )} ))} {/* Edit column */} diff --git a/src/components/mui/table/mui-table.js b/src/components/mui/table/mui-table.js index 136bf35e..db18e4db 100644 --- a/src/components/mui/table/mui-table.js +++ b/src/components/mui/table/mui-table.js @@ -32,12 +32,13 @@ import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import CheckIcon from "@mui/icons-material/Check"; import CloseIcon from "@mui/icons-material/Close"; -import {visuallyHidden} from "@mui/utils"; -import {DEFAULT_PER_PAGE, FIFTY_PER_PAGE, TWENTY_PER_PAGE} from "../../../utils/constants"; +import { visuallyHidden } from "@mui/utils"; +import { DEFAULT_PER_PAGE, FIFTY_PER_PAGE, TWENTY_PER_PAGE } from "../../../utils/constants"; import showConfirmDialog from "../showConfirmDialog"; import styles from "./mui-table.module.less"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import PropTypes from "prop-types"; +import EllipsisTooltip from "../ellipsis-tooltip"; const ARCHIVED_CELL_SX = { backgroundColor: "background.light", @@ -54,27 +55,27 @@ const ACTION_CELL_SX = { }; const MuiTable = ({ - columns = [], - data = [], - children, - totalRows, - perPage, - currentPage, - onPageChange, - onPerPageChange, - onSort, - options = {sortCol: "", sortDir: 1, disableProp: null}, // disableProp is the prop that will disable the row - getName = (item) => item.name, - onEdit, - onArchive, - onDelete, - onSelect, - canDelete = () => true, - deleteDialogTitle = null, - deleteDialogBody = null, - deleteDialogConfirmText = null, - confirmButtonColor = null - }) => { + columns = [], + data = [], + children, + totalRows, + perPage, + currentPage, + onPageChange, + onPerPageChange, + onSort, + options = { sortCol: "", sortDir: 1, disableProp: null }, // disableProp is the prop that will disable the row + getName = (item) => item.name, + onEdit, + onArchive, + onDelete, + onSelect, + canDelete = () => true, + deleteDialogTitle = null, + deleteDialogBody = null, + deleteDialogConfirmText = null, + confirmButtonColor = null +}) => { const totalColumnsCount = columns.length + (onEdit ? 1 : 0) + (onDelete ? 1 : 0) + (onArchive ? 1 : 0) + (onSelect ? 1 : 0); @@ -103,7 +104,7 @@ const MuiTable = ({ customPerPageOptions = [initialPerPage.current]; } - const {sortCol, sortDir} = options; + const { sortCol, sortDir } = options; const getDisabledSx = (row) => options.disableProp && row[options.disableProp] ? ARCHIVED_CELL_SX : {}; @@ -154,31 +155,39 @@ const MuiTable = ({ }; const renderCell = (row, col) => { - if (col.render) { - return col.render(row); - } - if (isBoolean(row[col.columnKey])) { return row[col.columnKey] ? ( - + ) : ( - + + ); + } + + const content = col.render + ? col.render(row) + : {row[col.columnKey]}; + + if (col.ellipsis) { + return ( + + {content} + ); } - return {row[col.columnKey]}; + return content; }; return ( - - + + - +
{/* TABLE HEADER */} - + {columns.map((col) => ( ))} - {onEdit && } - {onArchive && } - {onDelete && } - {onSelect && } + {onEdit && } + {onArchive && } + {onDelete && } + {onSelect && } @@ -242,18 +251,18 @@ const MuiTable = ({ onEdit(row)} - sx={{padding: 0}} + sx={{ padding: 0 }} data-testid="action-edit" disabled={options.disableProp && row[options.disableProp]} > - + )} {onArchive && (