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..ece03d3e 100644 --- a/src/components/mui/__tests__/mui-table.test.js +++ b/src/components/mui/__tests__/mui-table.test.js @@ -265,4 +265,35 @@ describe("MuiTable", () => { // MUI CheckIcon renders an SVG; just ensure no error expect(screen.getByRole("cell", { hidden: true })).toBeInTheDocument(); }); + + describe("ellipsis column prop", () => { + beforeEach(() => { + jest.spyOn(Element.prototype, "scrollWidth", "get").mockReturnValue(200); + jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(100); + }); + afterEach(() => jest.restoreAllMocks()); + + test("wraps cell in truncating span", () => { + setup({ columns: [{ columnKey: "name", header: "Name", ellipsis: true }] }); + expect(screen.getByText("Alice").parentElement).toHaveStyle({ + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" + }); + }); + + test("tooltip shows raw value when col.render is absent", async () => { + setup({ columns: [{ columnKey: "name", header: "Name", ellipsis: true }] }); + await userEvent.hover(screen.getByText("Alice").parentElement); + expect(await screen.findByRole("tooltip")).toHaveTextContent("Alice"); + }); + + test("tooltip matches rendered output when col.render transforms data", async () => { + const cols = [{ columnKey: "name", header: "Name", ellipsis: true, render: (row) => Formatted: {row.name} }]; + setup({ columns: cols }); + const cell = screen.getByText("Formatted: Alice"); + await userEvent.hover(cell.parentElement); + expect(await screen.findByRole("tooltip")).toHaveTextContent("Formatted: Alice"); + }); + }); }); diff --git a/src/components/mui/editable-table/mui-table-editable.js b/src/components/mui/editable-table/mui-table-editable.js index 3a9e75c9..7fd8deee 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", @@ -289,7 +290,11 @@ const MuiTableEditable = ({ {data.map((row) => ( - {columns.map((col) => ( + {columns.map((col) => { + const cellContent = col.render + ? col.render(row) + : {row[col.columnKey]}; + return ( handleCellClick(row, col.columnKey)} @@ -317,15 +322,16 @@ const MuiTableEditable = ({ } validation={col.validation} /> - ) : col.render ? ( - col.render(row) + ) : col.ellipsis ? ( + + {cellContent} + ) : ( - - {row[col.columnKey]} - + cellContent )} - ))} + ); + })} {onEdit && ( { + 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..a4c44ec1 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 = [], @@ -209,7 +210,9 @@ const MuiTableSortable = ({ }} > {/* Main content columns */} - {columns.map((col) => ( + {columns.map((col) => { + const cellContent = col.render?.(row) || {row[col.columnKey]}; + return ( - {col.render?.(row) || row[col.columnKey]} + {col.ellipsis ? ( + + {cellContent} + + ) : ( + cellContent + )} - ))} + ); + })} {/* Edit column */} {onEdit && ( 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 && (