Skip to content
Open
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
65 changes: 65 additions & 0 deletions src/components/mui/__tests__/ellipsis-tooltip.test.js
Original file line number Diff line number Diff line change
@@ -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(<EllipsisTooltip title="tip"><span>content</span></EllipsisTooltip>);
expect(screen.getByText("content")).toBeInTheDocument();
});

test("wrapper span has truncation styles", () => {
render(<EllipsisTooltip title="tip"><span>content</span></EllipsisTooltip>);
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(<EllipsisTooltip title="overflow tip"><span>long content</span></EllipsisTooltip>);
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(<EllipsisTooltip title="tip"><span>short</span></EllipsisTooltip>);
await userEvent.hover(screen.getByText("short").parentElement);

expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
});
31 changes: 31 additions & 0 deletions src/components/mui/__tests__/mui-table.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => <span>Formatted: {row.name}</span> }];
setup({ columns: cols });
const cell = screen.getByText("Formatted: Alice");
await userEvent.hover(cell.parentElement);
expect(await screen.findByRole("tooltip")).toHaveTextContent("Formatted: Alice");
});
});
});
20 changes: 13 additions & 7 deletions src/components/mui/editable-table/mui-table-editable.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -289,7 +290,11 @@ const MuiTableEditable = ({
<TableBody>
{data.map((row) => (
<TableRow key={row.id}>
{columns.map((col) => (
{columns.map((col) => {
const cellContent = col.render
? col.render(row)
: <span style={{ fontWeight: "normal" }}>{row[col.columnKey]}</span>;
return (
<TableCell
key={`${row.id}-${col.columnKey}`}
onClick={() => handleCellClick(row, col.columnKey)}
Expand Down Expand Up @@ -317,15 +322,16 @@ const MuiTableEditable = ({
}
validation={col.validation}
/>
) : col.render ? (
col.render(row)
) : col.ellipsis ? (
<EllipsisTooltip title={cellContent}>
{cellContent}
</EllipsisTooltip>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) : (
<span style={{ fontWeight: "normal" }}>
{row[col.columnKey]}
</span>
cellContent
)}
</TableCell>
))}
);
})}
{onEdit && (
<TableCell
sx={getCellSx(row)}
Expand Down
24 changes: 24 additions & 0 deletions src/components/mui/ellipsis-tooltip.js
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip title={isOverflowing ? title : ""} placement="top" componentsProps={{ tooltip: { sx: { fontSize: "1.2rem" } } }}>
<span
ref={ref}
onMouseEnter={() => {
if (ref.current)
setIsOverflowing(ref.current.scrollWidth > ref.current.offsetWidth);
}}
style={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{children}
</span>
</Tooltip>
);
};

export default EllipsisTooltip;
16 changes: 13 additions & 3 deletions src/components/mui/sortable-table/mui-table-sortable.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
TWENTY_PER_PAGE
} from "../../../utils/constants";
import showConfirmDialog from "../showConfirmDialog";
import EllipsisTooltip from "../ellipsis-tooltip";

const MuiTableSortable = ({
columns = [],
Expand Down Expand Up @@ -209,7 +210,9 @@ const MuiTableSortable = ({
}}
>
{/* Main content columns */}
{columns.map((col) => (
{columns.map((col) => {
const cellContent = col.render?.(row) || <span>{row[col.columnKey]}</span>;
return (
<TableCell
key={col.columnKey}
align={col.align ?? "left"}
Expand All @@ -218,9 +221,16 @@ const MuiTableSortable = ({
} ${col.className}`}
sx={{ fontWeight: "normal" }}
>
{col.render?.(row) || row[col.columnKey]}
{col.ellipsis ? (
<EllipsisTooltip title={cellContent}>
{cellContent}
</EllipsisTooltip>
) : (
cellContent
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</TableCell>
))}
);
})}
{/* Edit column */}
{onEdit && (
<TableCell
Expand Down
103 changes: 56 additions & 47 deletions src/components/mui/table/mui-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);

Expand Down Expand Up @@ -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 : {};
Expand Down Expand Up @@ -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] ? (
<CheckIcon fontSize="large"/>
<CheckIcon fontSize="large" />
) : (
<CloseIcon fontSize="large"/>
<CloseIcon fontSize="large" />
);
}

const content = col.render
? col.render(row)
: <span style={{ fontWeight: "normal" }}>{row[col.columnKey]}</span>;

if (col.ellipsis) {
return (
<EllipsisTooltip title={content}>
{content}
</EllipsisTooltip>
);
}

return <span style={{fontWeight: "normal"}}>{row[col.columnKey]}</span>;
return content;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<Box sx={{width: "100%"}}>
<Paper elevation={0} sx={{width: "100%", mb: 2}}>
<Box sx={{ width: "100%" }}>
<Paper elevation={0} sx={{ width: "100%", mb: 2 }}>
<TableContainer
component={Paper}
sx={{borderRadius: 0, boxShadow: "none"}}
sx={{ borderRadius: 0, boxShadow: "none" }}
>
<Table sx={{tableLayout: "fixed"}}>
<Table sx={{ tableLayout: "fixed" }}>
{/* TABLE HEADER */}
<TableHead sx={{backgroundColor: "#EAEDF4"}}>
<TableHead sx={{ backgroundColor: "#EAEDF4" }}>
<TableRow>
{columns.map((col) => (
<TableCell
Expand Down Expand Up @@ -210,10 +219,10 @@ const MuiTable = ({
)}
</TableCell>
))}
{onEdit && <TableCell sx={ACTION_CELL_SX}/>}
{onArchive && <TableCell sx={{...ACTION_CELL_SX, width: 80, minWidth: 80, maxWidth: 80}}/>}
{onDelete && <TableCell sx={ACTION_CELL_SX}/>}
{onSelect && <TableCell sx={ACTION_CELL_SX}/>}
{onEdit && <TableCell sx={ACTION_CELL_SX} />}
{onArchive && <TableCell sx={{ ...ACTION_CELL_SX, width: 80, minWidth: 80, maxWidth: 80 }} />}
{onDelete && <TableCell sx={ACTION_CELL_SX} />}
{onSelect && <TableCell sx={ACTION_CELL_SX} />}
</TableRow>
</TableHead>

Expand Down Expand Up @@ -242,18 +251,18 @@ const MuiTable = ({
<IconButton
size="medium"
onClick={() => onEdit(row)}
sx={{padding: 0}}
sx={{ padding: 0 }}
data-testid="action-edit"
disabled={options.disableProp && row[options.disableProp]}
>
<EditIcon fontSize="large"/>
<EditIcon fontSize="large" />
</IconButton>
</TableCell>
)}
{onArchive && (
<TableCell
align="center"
sx={{...getActionCellSx(row), width: 80, minWidth: 80, maxWidth: 80}}
sx={{ ...getActionCellSx(row), width: 80, minWidth: 80, maxWidth: 80 }}
className={styles.dottedBorderLeft}
>
<Button
Expand Down Expand Up @@ -291,10 +300,10 @@ const MuiTable = ({
size="medium"
onClick={() => handleDelete(row)}
data-testid="action-delete"
sx={{padding: 0}}
sx={{ padding: 0 }}
disabled={options.disableProp && row[options.disableProp]}
>
<DeleteIcon fontSize="large"/>
<DeleteIcon fontSize="large" />
</IconButton>
)}
</TableCell>
Expand All @@ -309,10 +318,10 @@ const MuiTable = ({
size="medium"
onClick={() => onSelect(row)}
data-testid="action-select"
sx={{padding: 0}}
sx={{ padding: 0 }}
disabled={options.disableProp && row[options.disableProp]}
>
<ArrowForwardIcon/>
<ArrowForwardIcon />
</IconButton>
</TableCell>
)}
Expand Down
Loading