diff --git a/src/components/mui/__tests__/mui-table.test.js b/src/components/mui/__tests__/mui-table.test.js
index 55435d0a..36565db5 100644
--- a/src/components/mui/__tests__/mui-table.test.js
+++ b/src/components/mui/__tests__/mui-table.test.js
@@ -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");
+ });
+ });
});
diff --git a/src/components/mui/__tests__/truncate-text.test.js b/src/components/mui/__tests__/truncate-text.test.js
new file mode 100644
index 00000000..d670eda5
--- /dev/null
+++ b/src/components/mui/__tests__/truncate-text.test.js
@@ -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(really long content);
+ 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(short);
+ 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(Hello World);
+ 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(short text);
+ expect(screen.getByText("short text")).toBeInTheDocument();
+
+ await userEvent.hover(screen.getByText("short text").parentElement);
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/mui/editable-table/mui-table-editable.js b/src/components/mui/editable-table/mui-table-editable.js
index 3a9e75c9..442e9b38 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 TableCellContent from "../table/table-content";
const ARCHIVED_CELL_SX = {
backgroundColor: "background.light",
@@ -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) ? (
@@ -317,12 +317,8 @@ const MuiTableEditable = ({
}
validation={col.validation}
/>
- ) : col.render ? (
- col.render(row)
) : (
-
- {row[col.columnKey]}
-
+
)}
))}
diff --git a/src/components/mui/sortable-table/mui-table-sortable.js b/src/components/mui/sortable-table/mui-table-sortable.js
index ade4f26d..ec090af2 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 TableCellContent from "../table/table-content";
const MuiTableSortable = ({
columns = [],
@@ -216,9 +217,8 @@ const MuiTableSortable = ({
className={`${
col.dottedBorder && styles.dottedBorderLeft
} ${col.className}`}
- sx={{ fontWeight: "normal" }}
>
- {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..1dbe07e4 100644
--- a/src/components/mui/table/mui-table.js
+++ b/src/components/mui/table/mui-table.js
@@ -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,
@@ -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",
@@ -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);
@@ -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 : {};
@@ -118,7 +116,6 @@ const MuiTable = ({
})
const getCellSx = (row, col) => ({
- fontWeight: "normal",
...(col.width && {
width: col.width,
minWidth: col.width,
@@ -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] ? (
-
- ) : (
-
- );
- }
-
- return {row[col.columnKey]};
- };
-
return (
-
-
+
+
-
+
{/* TABLE HEADER */}
-
+
{columns.map((col) => (
))}
- {onEdit && }
- {onArchive && }
- {onDelete && }
- {onSelect && }
+ {onEdit && }
+ {onArchive && }
+ {onDelete && }
+ {onSelect && }
@@ -229,7 +210,7 @@ const MuiTable = ({
className={`${col.dottedBorder && styles.dottedBorderLeft} ${col.className}`}
sx={getCellSx(row, col)}
>
- {renderCell(row, col)}
+
))}
{/* Edit column */}
@@ -242,18 +223,18 @@ const MuiTable = ({
onEdit(row)}
- sx={{padding: 0}}
+ sx={{ padding: 0 }}
data-testid="action-edit"
disabled={options.disableProp && row[options.disableProp]}
>
-
+
)}
{onArchive && (
@@ -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]}
>
-
+
)}
diff --git a/src/components/mui/table/table-content.js b/src/components/mui/table/table-content.js
new file mode 100644
index 00000000..5a8bfe2a
--- /dev/null
+++ b/src/components/mui/table/table-content.js
@@ -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 (
+
+ {isBoolean(row[col.columnKey]) ? (
+ row[col.columnKey] ? (
+
+ ) : (
+
+ )
+ ) : col.render ? (
+ col.render(row)
+ ) : col.truncateText ? (
+
+ {row[col.columnKey]}
+
+ ) : (
+ row[col.columnKey]
+ )}
+
+ );
+};
+
+export default TableCellContent;
\ No newline at end of file
diff --git a/src/components/mui/truncate-text.js b/src/components/mui/truncate-text.js
new file mode 100644
index 00000000..f3d09e9a
--- /dev/null
+++ b/src/components/mui/truncate-text.js
@@ -0,0 +1,36 @@
+import * as React from "react";
+import PropTypes from "prop-types";
+import Tooltip from "@mui/material/Tooltip";
+
+const TruncateText = ({ children, charLimit }) => {
+ const ref = React.useRef(null);
+ const [isOverflowing, setIsOverflowing] = React.useState(false);
+
+ const shouldCharTruncate = typeof charLimit === "number";
+ const isTruncated = shouldCharTruncate && children.length > charLimit;
+
+ const displayContent = isTruncated ? children.slice(0, charLimit) + "..." : children;
+ const tooltipTitle = isTruncated ? children : isOverflowing ? children : "";
+
+ return (
+
+ {
+ if (ref.current && !shouldCharTruncate)
+ setIsOverflowing(ref.current.scrollWidth > ref.current.offsetWidth);
+ }}
+ style={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
+ >
+ {displayContent}
+
+
+ );
+};
+
+TruncateText.propTypes = {
+ children: PropTypes.string,
+ charLimit: PropTypes.oneOfType([PropTypes.bool, PropTypes.number]),
+};
+
+export default TruncateText;