Skip to content
Open
22 changes: 22 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,26 @@ 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 and shows raw value tooltip on hover", async () => {
setup({ columns: [{ columnKey: "name", header: "Name", truncateText: true }] });
const wrapper = screen.getByText("Alice").parentElement;

expect(wrapper).toHaveStyle({
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
});

await userEvent.hover(wrapper);
expect(await screen.findByRole("tooltip")).toHaveTextContent("Alice");
});
});
});
76 changes: 76 additions & 0 deletions src/components/mui/__tests__/truncate-text.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* 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 TruncateText from "../truncate-text";

describe("TruncateText", () => {
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();
});

describe("css mode (charLimit={true})", () => {
test("wrapper span has truncation styles and shows tooltip on hover when content overflows", async () => {
scrollWidthSpy.mockReturnValue(200);
offsetWidthSpy.mockReturnValue(100);

render(<TruncateText charLimit={true}>really long content</TruncateText>);
const wrapper = screen.getByText("really long content").parentElement;

expect(wrapper).toHaveStyle({
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
});

await userEvent.hover(wrapper);
expect(await screen.findByRole("tooltip")).toHaveTextContent("really long content");
});

test("does not show tooltip when content fits", async () => {
render(<TruncateText charLimit={true}>short</TruncateText>);
await userEvent.hover(screen.getByText("short").parentElement);

expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
});

describe("character limit mode (charLimit={number})", () => {
test("truncates display and shows full content in tooltip when beyond charLimit", async () => {
render(<TruncateText charLimit={5}>Hello World</TruncateText>);
expect(screen.getByText("Hello...")).toBeInTheDocument();

await userEvent.hover(screen.getByText("Hello...").parentElement);
expect(await screen.findByRole("tooltip")).toHaveTextContent("Hello World");
});

test("renders content unchanged and shows no tooltip when within charLimit", async () => {
render(<TruncateText charLimit={20}>short text</TruncateText>);
expect(screen.getByText("short text")).toBeInTheDocument();

await userEvent.hover(screen.getByText("short text").parentElement);
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
});
});
10 changes: 3 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 TableCellContent from "../table/table-content";

const ARCHIVED_CELL_SX = {
backgroundColor: "background.light",
Expand Down Expand Up @@ -295,8 +296,7 @@ const MuiTableEditable = ({
onClick={() => handleCellClick(row, col.columnKey)}
sx={getCellSx(row, {
cursor: isEditable(col, row) ? "pointer" : "default",
padding: isEditable(col, row) ? "8px 16px" : undefined,
fontWeight: "normal"
padding: isEditable(col, row) ? "8px 16px" : undefined
})}
>
{isEditable(col, row) ? (
Expand All @@ -317,12 +317,8 @@ const MuiTableEditable = ({
}
validation={col.validation}
/>
) : col.render ? (
col.render(row)
) : (
<span style={{ fontWeight: "normal" }}>
{row[col.columnKey]}
</span>
<TableCellContent row={row} col={col} />
)}
</TableCell>
))}
Expand Down
4 changes: 2 additions & 2 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 TableCellContent from "../table/table-content";

const MuiTableSortable = ({
columns = [],
Expand Down Expand Up @@ -216,9 +217,8 @@ const MuiTableSortable = ({
className={`${
col.dottedBorder && styles.dottedBorderLeft
} ${col.className}`}
sx={{ fontWeight: "normal" }}
>
{col.render?.(row) || row[col.columnKey]}
<TableCellContent row={row} col={col} />
</TableCell>
))}
{/* Edit column */}
Expand Down
103 changes: 42 additions & 61 deletions src/components/mui/table/mui-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

import * as React from "react";
import T from "i18n-react/dist/i18n-react";
import isBoolean from "lodash/isBoolean";
import {
Box,
Button,
Expand All @@ -30,14 +29,13 @@ import {
} from "@mui/material";
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 TableCellContent from "./table-content";

const ARCHIVED_CELL_SX = {
backgroundColor: "background.light",
Expand All @@ -54,27 +52,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 +101,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 All @@ -118,7 +116,6 @@ const MuiTable = ({
})

const getCellSx = (row, col) => ({
fontWeight: "normal",
...(col.width && {
width: col.width,
minWidth: col.width,
Expand Down Expand Up @@ -153,32 +150,16 @@ 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"/>
) : (
<CloseIcon fontSize="large"/>
);
}

return <span style={{fontWeight: "normal"}}>{row[col.columnKey]}</span>;
};

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 +191,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 All @@ -229,7 +210,7 @@ const MuiTable = ({
className={`${col.dottedBorder && styles.dottedBorderLeft} ${col.className}`}
sx={getCellSx(row, col)}
>
{renderCell(row, col)}
<TableCellContent row={row} col={col} />
</TableCell>
))}
{/* Edit column */}
Expand All @@ -242,18 +223,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 +272,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 +290,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
34 changes: 34 additions & 0 deletions src/components/mui/table/table-content.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as React from "react";
import isBoolean from "lodash/isBoolean";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import TruncateText from "../truncate-text";

const contentStyle = {
fontWeight: "normal",
wordBreak: "break-all",
};

const TableCellContent = ({ row, col }) => {
return (
<span style={contentStyle}>
{isBoolean(row[col.columnKey]) ? (
row[col.columnKey] ? (
<CheckIcon fontSize="large" />
) : (
<CloseIcon fontSize="large" />
)
) : col.render ? (
col.render(row)
) : col.truncateText ? (
<TruncateText charLimit={col.truncateText}>
{row[col.columnKey]}
</TruncateText>
) : (
row[col.columnKey]
)}
</span>
);
};

export default TableCellContent;
Loading
Loading