diff --git a/src/components/index.js b/src/components/index.js index b77daffd..9c310751 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -76,12 +76,12 @@ export {default as MuiNotesModal} from './mui/NotesModal' export {default as MuiSnackbarNotification} from './mui/SnackbarNotification' export {useSnackbarMessage} from './mui/SnackbarNotification/Context' export {default as MuiInfiniteTable} from './mui/infinite-table' -export {default as MuiEditableTable} from './mui/editable-table/mui-table-editable' -export {default as MuiTable} from './mui/table/mui-table' -export {default as MuiCustomTablePagination} from './mui/table/CustomTablePagination' +export {default as MuiEditableTable} from './mui/tables/editable-table' +export {default as MuiTable} from './mui/tables/mui-table' +export {default as MuiCustomTablePagination} from './mui/tables/components/CustomTablePagination' export {default as MuiBulkEditTable} from './mui/BulkEditTable' export {default as MuiSponsorOrderGrid} from './mui/SponsorOrderGrid' -export {TotalRow as MuiTotalRow, NotesRow as MuiNotesRow, FeeRow as MuiFeeRow, PaymentRow as MuiPaymentRow, RefundRow as MuiRefundRow, DiscountRow as MuiDiscountRow} from './mui/table/extra-rows' +export {TotalRow as MuiTotalRow, NotesRow as MuiNotesRow, FeeRow as MuiFeeRow, PaymentRow as MuiPaymentRow, RefundRow as MuiRefundRow, DiscountRow as MuiDiscountRow} from './mui/tables/extra-rows' export {default as MuiFormikAsyncSelect} from './mui/formik-inputs/mui-formik-async-select' export {default as MuiFormikCheckboxGroup} from './mui/formik-inputs/mui-formik-checkbox-group' export {default as MuiFormikCheckbox} from './mui/formik-inputs/mui-formik-checkbox' @@ -136,7 +136,7 @@ export {MuiBaseCustomTheme} from './mui/MuiBaseCustomTheme' // export {default as TextEditorV3} from './inputs/editor-input-v3' // export {default as CompanyInputV2} from './inputs/company-input-v2.js' // export {default as MuiDndList} from './mui/dnd-list' // react-beautiful-dnd -// export {default as MuiSortableTable} from './mui/sortable-table/mui-table-sortable' // react-beautiful-dnd +// export {default as MuiSortableTable} from './mui/tables/sortable-table' // react-beautiful-dnd // export {default as MuiStripePayment} from './mui/StripePayment' // @stripe/react-stripe-js, @stripe/stripe-js // export {default as MuiAdditionalInput} from './mui/formik-inputs/additional-input/additional-input' // react-beautiful-dnd (via dnd-list) // export {default as MuiAdditionalInputList} from './mui/formik-inputs/additional-input/additional-input-list' // react-beautiful-dnd (via dnd-list) diff --git a/src/components/mui/BulkEditTable/BulkEditTable.js b/src/components/mui/BulkEditTable/BulkEditTable.js index 4da11ea2..604d88fa 100644 --- a/src/components/mui/BulkEditTable/BulkEditTable.js +++ b/src/components/mui/BulkEditTable/BulkEditTable.js @@ -28,7 +28,7 @@ import Heading from "./components/Heading"; import Row from "./components/Row"; import useRowSelection from "./hooks/useRowSelection"; import styles from "./BulkEditTable.module.less"; -import CustomTablePagination from "../table/CustomTablePagination"; +import CustomTablePagination from "../tables/components/CustomTablePagination"; import showConfirmDialog from "../showConfirmDialog"; const BulkEditTable = ({ diff --git a/src/components/mui/MuiBaseCustomTheme.js b/src/components/mui/MuiBaseCustomTheme.js index b8689919..b2cf647f 100644 --- a/src/components/mui/MuiBaseCustomTheme.js +++ b/src/components/mui/MuiBaseCustomTheme.js @@ -44,6 +44,13 @@ export const MuiBaseCustomTheme = { height: "24px" } } + }, + MuiTooltip: { + styleOverrides: { + tooltip: { + fontSize: "12px" + } + } } } }; diff --git a/src/components/mui/SponsorOrderGrid/index.js b/src/components/mui/SponsorOrderGrid/index.js index 0e0878f5..84d4b1fa 100644 --- a/src/components/mui/SponsorOrderGrid/index.js +++ b/src/components/mui/SponsorOrderGrid/index.js @@ -25,7 +25,7 @@ import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; import UndoIcon from "@mui/icons-material/Undo"; import DeleteIcon from "@mui/icons-material/Delete"; -import {DiscountRow, FeeRow, NotesRow, PaymentRow, RefundRow, TotalRow} from "../table/extra-rows"; +import {DiscountRow, FeeRow, NotesRow, PaymentRow, RefundRow, TotalRow} from "../tables/extra-rows"; import {SPONSOR_ORDER_GRID_ITEM_TYPES} from "../../../utils/constants"; import InfoNote from "../InfoNote"; import {currencyAmountFromCents} from "../../../utils/money"; diff --git a/src/components/mui/__tests__/fee-row.test.js b/src/components/mui/__tests__/fee-row.test.js index 2ee57cc7..7450de2c 100644 --- a/src/components/mui/__tests__/fee-row.test.js +++ b/src/components/mui/__tests__/fee-row.test.js @@ -23,7 +23,7 @@ jest.mock("../../../utils/money", () => ({ import React from "react"; import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; -import FeeRow from "../table/extra-rows/FeeRow"; +import FeeRow from "../tables/extra-rows/FeeRow"; const renderInTable = (props) => render( diff --git a/src/components/mui/__tests__/mui-table-editable.test.js b/src/components/mui/__tests__/mui-table-editable.test.js index d76a0abd..562c15a6 100644 --- a/src/components/mui/__tests__/mui-table-editable.test.js +++ b/src/components/mui/__tests__/mui-table-editable.test.js @@ -139,7 +139,7 @@ import "@testing-library/jest-dom"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import MuiTableEditable from "../editable-table/mui-table-editable"; +import MuiTableEditable from "../tables/editable-table"; import showConfirmDialog from "../showConfirmDialog"; /* eslint-enable import/first */ diff --git a/src/components/mui/__tests__/mui-table-sortable.test.js b/src/components/mui/__tests__/mui-table-sortable.test.js index ccd7474a..70d5a49c 100644 --- a/src/components/mui/__tests__/mui-table-sortable.test.js +++ b/src/components/mui/__tests__/mui-table-sortable.test.js @@ -70,7 +70,7 @@ import React from "react"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; -import MuiTableSortable from "../sortable-table/mui-table-sortable"; +import MuiTableSortable from "../tables/sortable-table"; import showConfirmDialog from "../showConfirmDialog"; const columns = [ diff --git a/src/components/mui/__tests__/mui-table.test.js b/src/components/mui/__tests__/mui-table.test.js index 36565db5..904cedbe 100644 --- a/src/components/mui/__tests__/mui-table.test.js +++ b/src/components/mui/__tests__/mui-table.test.js @@ -52,7 +52,7 @@ import React from "react"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; -import MuiTable from "../table/mui-table"; +import MuiTable from "../tables/mui-table"; import showConfirmDialog from "../showConfirmDialog"; const columns = [ diff --git a/src/components/mui/__tests__/notes-row.test.js b/src/components/mui/__tests__/notes-row.test.js index e4ad2abd..7096d630 100644 --- a/src/components/mui/__tests__/notes-row.test.js +++ b/src/components/mui/__tests__/notes-row.test.js @@ -14,7 +14,7 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; -import NotesRow from "../table/extra-rows/NotesRow"; +import NotesRow from "../tables/extra-rows/NotesRow"; const renderInTable = (props) => render( diff --git a/src/components/mui/__tests__/payment-row.test.js b/src/components/mui/__tests__/payment-row.test.js index 08adb812..e7377ae0 100644 --- a/src/components/mui/__tests__/payment-row.test.js +++ b/src/components/mui/__tests__/payment-row.test.js @@ -23,7 +23,7 @@ jest.mock("../../../utils/money", () => ({ import React from "react"; import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; -import PaymentRow from "../table/extra-rows/PaymentRow"; +import PaymentRow from "../tables/extra-rows/PaymentRow"; // 2026-01-15 00:00:00 UTC in seconds const PAYMENT_TIMESTAMP = 1736899200; diff --git a/src/components/mui/__tests__/refund-row.test.js b/src/components/mui/__tests__/refund-row.test.js index 11bc5496..83f01af4 100644 --- a/src/components/mui/__tests__/refund-row.test.js +++ b/src/components/mui/__tests__/refund-row.test.js @@ -23,7 +23,7 @@ jest.mock("../../../utils/money", () => ({ import React from "react"; import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; -import RefundRow from "../table/extra-rows/RefundRow"; +import RefundRow from "../tables/extra-rows/RefundRow"; const renderInTable = (props) => render( diff --git a/src/components/mui/__tests__/total-row.test.js b/src/components/mui/__tests__/total-row.test.js index a278922f..807f5c6a 100644 --- a/src/components/mui/__tests__/total-row.test.js +++ b/src/components/mui/__tests__/total-row.test.js @@ -19,7 +19,7 @@ jest.mock("i18n-react/dist/i18n-react", () => ({ import React from "react"; import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; -import TotalRow from "../table/extra-rows/TotalRow"; +import TotalRow from "../tables/extra-rows/TotalRow"; const columns = [ { columnKey: "name", header: "Name" }, diff --git a/src/components/mui/editable-table/mui-table-editable.js b/src/components/mui/editable-table/mui-table-editable.js deleted file mode 100644 index 39441db4..00000000 --- a/src/components/mui/editable-table/mui-table-editable.js +++ /dev/null @@ -1,412 +0,0 @@ -/** - * 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 * as React from "react"; -import PropTypes from "prop-types"; -import T from "i18n-react/dist/i18n-react"; -import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; -import Table from "@mui/material/Table"; -import TableBody from "@mui/material/TableBody"; -import TableCell from "@mui/material/TableCell"; -import TableContainer from "@mui/material/TableContainer"; -import TableHead from "@mui/material/TableHead"; -import TableSortLabel from "@mui/material/TableSortLabel"; -import TableRow from "@mui/material/TableRow"; -import Paper from "@mui/material/Paper"; -import { IconButton, TextField } from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { visuallyHidden } from "@mui/utils"; -import styles from "./mui-table-editable.module.less"; - -import showConfirmDialog from "../showConfirmDialog"; -import TableCellContent from "../table/table-cell-content"; -import CustomTablePagination from "../table/CustomTablePagination"; - -const ARCHIVED_CELL_SX = { - backgroundColor: "background.light", - color: "text.disabled" -}; - -const validateValue = (value, validation) => { - if (!validation) return { isValid: true }; - - // validate with yup schema - if ( - validation.schema && - typeof validation.schema.validateSync === "function" - ) { - try { - validation.schema.validateSync(value); - return { isValid: true, message: null }; - } catch (err) { - return { isValid: false, message: err.message }; - } - } - - return { isValid: true }; -}; - -// Updated component to handle editable cells with hover edit icon -const EditableCell = ({ value, isEditing, onBlur, validation }) => { - const [inputValue, setInputValue] = React.useState(value); - const [isHovering, setIsHovering] = React.useState(false); - const [error, setError] = React.useState(null); - - React.useEffect(() => { - setInputValue(value); - setError(null); - }, [value]); - - const handleValidationAndSave = (newValue) => { - const { isValid, message } = validateValue(newValue, validation); - - if (isValid) { - setError(null); - onBlur(newValue, true); - } else { - setError(message); - } - }; - - const handleKeyDown = (e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleValidationAndSave(inputValue); - } - }; - - if (isEditing) { - return ( - { - setInputValue(e.target.value); - if (error) setError(null); - }} - onBlur={() => { - handleValidationAndSave(inputValue); - }} - onKeyDown={handleKeyDown} - size="small" - fullWidth - variant="standard" - error={!!error} - helperText={error} - /> - ); - } - - return ( - setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - > - {value} - {isHovering && ( - - )} - - ); -}; - -const MuiTableEditable = ({ - columns = [], - data = [], - totalRows, - perPage, - currentPage, - onPageChange, - onPerPageChange, - onSort, - options = { sortCol: "", sortDir: 1, disableProp: null }, - getName = (item) => item.name, - onEdit, - onArchive, - onDelete, - onCellChange, // New prop for handling cell value changes - deleteDialogBody, - tableSx = {} -}) => { - // State to track which cell is currently being edited - const [editingCell, setEditingCell] = React.useState(null); - - const { sortCol, sortDir } = options; - - const getArchivedCellSx = (row) => - options.disableProp && row[options.disableProp] ? ARCHIVED_CELL_SX : null; - - const getCellSx = (row, baseSx = {}) => ({ - ...baseSx, - ...(getArchivedCellSx(row) || {}) - }); - - const handleDelete = async (item) => { - const isConfirmed = await showConfirmDialog({ - title: T.translate("general.are_you_sure"), - text: deleteDialogBody - ? deleteDialogBody(getName(item)) - : `${T.translate("general.row_remove_warning")} ${getName(item)}`, - type: "warning", - showCancelButton: true, - confirmButtonColor: "warning", - confirmButtonText: T.translate("general.yes_delete") - }); - - if (isConfirmed) { - onDelete(item.id); - } - }; - - const isEditable = (col, row) => - typeof col.editable === "function" ? col.editable(row) : !!col.editable; - - // Handler for starting edit mode on a cell - const handleCellClick = (row, columnKey) => { - // Check if the column is editable - const column = columns.find((col) => col.columnKey === columnKey); - if (column && isEditable(column, row)) { - setEditingCell({ rowId: row.id, columnKey }); - } - }; - - // Handler for saving changes when editing is complete - const handleCellBlur = (rowId, columnKey, newValue, isValid) => { - if (onCellChange && isValid) { - onCellChange(rowId, columnKey, newValue); - } - setEditingCell(null); - }; - - return ( - - - - - {/* TABLE HEADER */} - - - {columns.map((col) => ( - - {col.sortable ? ( - onSort(col.columnKey, sortDir * -1)} - sx={{ fontWeight: "normal" }} - > - {col.header} - {sortCol === col.columnKey ? ( - - {sortDir === -1 - ? T.translate("mui_table.sorted_desc") - : T.translate("mui_table.sorted_asc")} - - ) : null} - - ) : ( - {col.header} - )} - - ))} - {onEdit && ( - - )} - {onArchive && ( - - )} - {onDelete && ( - - )} - - - {/* TABLE BODY */} - - {data.map((row) => ( - - {columns.map((col) => ( - handleCellClick(row, col.columnKey)} - sx={getCellSx(row, { - cursor: isEditable(col, row) ? "pointer" : "default", - padding: isEditable(col, row) ? "8px 16px" : undefined - })} - > - {isEditable(col, row) ? ( - - handleCellBlur( - row.id, - col.columnKey, - newValue, - isValid - ) - } - validation={col.validation} - /> - ) : ( - - )} - - ))} - {onEdit && ( - - onEdit(row)} - size="small" - aria-label={T.translate("general.edit")} - > - - - - )} - {onArchive && ( - - - - )} - {onDelete && ( - - handleDelete(row)} - size="small" - aria-label={T.translate("general.delete")} - > - - - - )} - - ))} - -
-
- {perPage && currentPage && onPageChange && ( - - )} -
-
- ); -}; - -MuiTableEditable.propTypes = { - columns: PropTypes.arrayOf( - PropTypes.shape({ - columnKey: PropTypes.string.isRequired, - header: PropTypes.node, - width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - align: PropTypes.string, - sortable: PropTypes.bool, - editable: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), - validation: PropTypes.shape({ schema: PropTypes.object }), - render: PropTypes.func - }) - ), - data: PropTypes.arrayOf(PropTypes.object), - totalRows: PropTypes.number, - // Pagination only renders when all three are provided — see line 366. - perPage: PropTypes.number, - currentPage: PropTypes.number, - onPageChange: PropTypes.func, - onPerPageChange: PropTypes.func, - onSort: PropTypes.func, - options: PropTypes.shape({ - sortCol: PropTypes.string, - sortDir: PropTypes.oneOf([1, -1]), - disableProp: PropTypes.string - }), - getName: PropTypes.func, - onEdit: PropTypes.func, - onArchive: PropTypes.func, - onDelete: PropTypes.func, - onCellChange: PropTypes.func, - deleteDialogBody: PropTypes.func -}; - -export default MuiTableEditable; diff --git a/src/components/mui/editable-table/mui-table-editable.module.less b/src/components/mui/editable-table/mui-table-editable.module.less deleted file mode 100644 index d056f4b5..00000000 --- a/src/components/mui/editable-table/mui-table-editable.module.less +++ /dev/null @@ -1,14 +0,0 @@ -.dottedBorderLeft { - position: relative; - border-left: none; - &::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; - border-left: 1px dashed #e0e0e0; - height: auto; - margin: 20px 0; - } -} diff --git a/src/components/mui/sortable-table/mui-table-sortable.js b/src/components/mui/sortable-table/mui-table-sortable.js deleted file mode 100644 index 0ad27a52..00000000 --- a/src/components/mui/sortable-table/mui-table-sortable.js +++ /dev/null @@ -1,281 +0,0 @@ -/** - * 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 * as React from "react"; -import T from "i18n-react/dist/i18n-react"; -import Box from "@mui/material/Box"; -import Table from "@mui/material/Table"; -import TableBody from "@mui/material/TableBody"; -import TableCell from "@mui/material/TableCell"; -import TableContainer from "@mui/material/TableContainer"; -import TableHead from "@mui/material/TableHead"; -import TableSortLabel from "@mui/material/TableSortLabel"; -import TableRow from "@mui/material/TableRow"; -import Paper from "@mui/material/Paper"; -import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore"; -import { IconButton } from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd"; -import { visuallyHidden } from "@mui/utils"; - -import styles from "./styles.module.less"; - -import showConfirmDialog from "../showConfirmDialog"; -import TableCellContent from "../table/table-cell-content"; -import CustomTablePagination from "../table/CustomTablePagination"; - -const MuiTableSortable = ({ - columns = [], - data = [], - totalRows, - perPage, - currentPage, - onPageChange, - onPerPageChange, - onSort, - options = { sortCol: "", sortDir: 1 }, - getName = (item) => item.name, - onEdit, - onDelete, - deleteDialogTitle = null, - deleteDialogBody = null, - onReorder, - idKey = "id", - updateOrderKey = "order", - tableSx = {} -}) => { - const { sortCol, sortDir } = options; - - const handleDragEnd = (result) => { - if (!result.destination || result.source.index === result.destination.index) - return; - - const reordered = [...data]; - const [movedItem] = reordered.splice(result.source.index, 1); - reordered.splice(result.destination.index, 0, movedItem); - - // change value based on updateOrderKey - if (updateOrderKey) { - reordered.forEach((item, idx) => { - item[updateOrderKey] = idx + 1; - }); - } - - const movedItemId = movedItem.id; - const newOrder = reordered.find( - (item) => item[idKey || "id"] === movedItemId - )?.[updateOrderKey]; - - onReorder?.(reordered, movedItemId, newOrder); - }; - - const handleDelete = async (item) => { - const isConfirmed = await showConfirmDialog({ - title: deleteDialogTitle || T.translate("general.are_you_sure"), - text: - typeof deleteDialogBody === "function" - ? deleteDialogBody(getName(item)) - : deleteDialogBody || - `${T.translate("general.row_remove_warning")} ${getName(item)}`, - type: "warning", - showCancelButton: true, - confirmButtonColor: "#DD6B55", - confirmButtonText: T.translate("general.yes_delete") - }); - - if (isConfirmed) { - onDelete(item.id); - } - }; - - return ( - - - - - {/* TABLE HEADER */} - - - {columns.map((col) => ( - - {col.sortable ? ( - onSort(col.columnKey, sortDir * -1)} - > - {col.header} - {sortCol === col.columnKey ? ( - - {sortDir === -1 - ? T.translate("mui_table.sorted_desc") - : T.translate("mui_table.sorted_asc")} - - ) : null} - - ) : ( - col.header - )} - - ))} - {onEdit && } - {onDelete && } - {onReorder && } - - - - {/* TABLE BODY */} - - - {(droppableProvided) => ( - - {data.map((row, rowIndex) => ( - - {(provided, snapshot) => ( - - {/* Main content columns */} - {columns.map((col) => ( - - - - ))} - {/* Edit column */} - {onEdit && ( - - onEdit(row)} - sx={{ padding: 0 }} - > - - - - )} - {/* Delete column */} - {onDelete && ( - - handleDelete(row)} - sx={{ padding: 0 }} - > - - - - )} - {/* Re order column */} - {onReorder && ( - - - - - - )} - - )} - - ))} - {droppableProvided.placeholder} - {data.length === 0 && ( - - - {T.translate("mui_table.no_items")} - - - )} - - )} - - -
-
- - {/* PAGINATION */} - {perPage && currentPage && onPageChange && ( - - )} -
-
- ); -}; - -export default MuiTableSortable; diff --git a/src/components/mui/sortable-table/styles.module.less b/src/components/mui/sortable-table/styles.module.less deleted file mode 100644 index d056f4b5..00000000 --- a/src/components/mui/sortable-table/styles.module.less +++ /dev/null @@ -1,14 +0,0 @@ -.dottedBorderLeft { - position: relative; - border-left: none; - &::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; - border-left: 1px dashed #e0e0e0; - height: auto; - margin: 20px 0; - } -} diff --git a/src/components/mui/table/mui-table.js b/src/components/mui/table/mui-table.js deleted file mode 100644 index 2b24baed..00000000 --- a/src/components/mui/table/mui-table.js +++ /dev/null @@ -1,329 +0,0 @@ -/** - * 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 * as React from "react"; -import T from "i18n-react/dist/i18n-react"; -import { - Box, - Button, - IconButton, - Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TableSortLabel -} from "@mui/material"; -import EditIcon from "@mui/icons-material/Edit"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { visuallyHidden } from "@mui/utils"; -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-cell-content"; -import CustomTablePagination from "./CustomTablePagination"; - -const ARCHIVED_CELL_SX = { - backgroundColor: "background.light", - color: "text.disabled" -}; - -const ACTION_CELL_SX = { - p: 0, - textAlign: "center", - verticalAlign: "middle", - width: 40, - minWidth: 40, - maxWidth: 40 -}; - -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, - tableSx = {} -}) => { - const totalColumnsCount = - columns.length + (onEdit ? 1 : 0) + (onDelete ? 1 : 0) + (onArchive ? 1 : 0) + (onSelect ? 1 : 0); - - const {sortCol, sortDir} = options; - - const getDisabledSx = (row) => - options.disableProp && row[options.disableProp] ? ARCHIVED_CELL_SX : {}; - - const getHeaderSx = (col) => ({ - ...(col.width && { - width: col.width, - minWidth: col.width, - maxWidth: col.width - }), - ...(col.headSx || {}), - }) - - const getCellSx = (row, col) => ({ - ...(col.width && { - width: col.width, - minWidth: col.width, - maxWidth: col.width - }), - ...(col.cellSx || {}), - ...getDisabledSx(row) - }); - - const getActionCellSx = (row) => ({ - ...ACTION_CELL_SX, - ...getDisabledSx(row) - }); - - const handleDelete = async (item) => { - const isConfirmed = await showConfirmDialog({ - title: deleteDialogTitle || T.translate("general.are_you_sure"), - text: - typeof deleteDialogBody === "function" - ? deleteDialogBody(getName(item)) - : deleteDialogBody || - `${T.translate("general.row_remove_warning")} ${getName(item)}`, - type: "warning", - showCancelButton: true, - confirmButtonColor: confirmButtonColor || "error", - confirmButtonText: - deleteDialogConfirmText || T.translate("general.yes_delete") - }); - - if (isConfirmed) { - onDelete(item.id); - } - }; - - return ( - - - - - {/* TABLE HEADER */} - - - {columns.map((col) => ( - - {col.sortable ? ( - onSort(col.columnKey, sortDir * -1)} - > - {col.header} - {sortCol === col.columnKey ? ( - - {sortDir === -1 - ? T.translate("mui_table.sorted_desc") - : T.translate("mui_table.sorted_asc")} - - ) : null} - - ) : ( - col.header - )} - - ))} - {onEdit && } - {onArchive && } - {onDelete && } - {onSelect && } - - - - {/* TABLE BODY */} - - {data.map((row) => ( - - {/* Main content columns */} - {columns.map((col) => ( - - - - ))} - {/* Edit column */} - {onEdit && ( - - onEdit(row)} - sx={{ padding: 0 }} - data-testid="action-edit" - disabled={options.disableProp && row[options.disableProp]} - > - - - - )} - {onArchive && ( - - - - )} - {/* Delete column */} - {onDelete && ( - - {canDelete(row) && ( - handleDelete(row)} - data-testid="action-delete" - sx={{ padding: 0 }} - disabled={options.disableProp && row[options.disableProp]} - > - - - )} - - )} - {onSelect && ( - - onSelect(row)} - data-testid="action-select" - sx={{ padding: 0 }} - disabled={options.disableProp && row[options.disableProp]} - > - - - - )} - - ))} - {/* Here we inject extra rows passed as children */} - {children} - {data.length === 0 && ( - - - {T.translate("mui_table.no_items")} - - - )} - -
-
- - {/* PAGINATION */} - {perPage && currentPage && onPageChange && ( - - )} -
-
- ); -}; - -MuiTable.propTypes = { - columns: PropTypes.array, - data: PropTypes.array, - children: PropTypes.node, - totalRows: PropTypes.number, - perPage: PropTypes.number, - currentPage: PropTypes.number, - onPageChange: PropTypes.func, - onPerPageChange: PropTypes.func, - onSort: PropTypes.func, - options: PropTypes.object, - getName: PropTypes.func, - onEdit: PropTypes.func, - onArchive: PropTypes.func, - onDelete: PropTypes.func, - onSelect: PropTypes.func, - canDelete: PropTypes.func, - deleteDialogTitle: PropTypes.string, - deleteDialogBody: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), - deleteDialogConfirmText: PropTypes.string, - confirmButtonColor: PropTypes.string, - tableSx: PropTypes.object -}; - -export default MuiTable; diff --git a/src/components/mui/table/CustomTablePagination.js b/src/components/mui/tables/components/CustomTablePagination.js similarity index 98% rename from src/components/mui/table/CustomTablePagination.js rename to src/components/mui/tables/components/CustomTablePagination.js index 36a636c1..3f490f1f 100644 --- a/src/components/mui/table/CustomTablePagination.js +++ b/src/components/mui/tables/components/CustomTablePagination.js @@ -15,7 +15,7 @@ import * as React from "react"; import T from "i18n-react/dist/i18n-react"; import TablePagination from "@mui/material/TablePagination"; import PropTypes from "prop-types"; -import { DEFAULT_PER_PAGE, FIFTY_PER_PAGE, TWENTY_PER_PAGE } from "../../../utils/constants"; +import { DEFAULT_PER_PAGE, FIFTY_PER_PAGE, TWENTY_PER_PAGE } from "../../../../utils/constants"; const PAGINATION_SX = { ".MuiTablePagination-toolbar": { diff --git a/src/components/mui/tables/components/create-delete-handler.js b/src/components/mui/tables/components/create-delete-handler.js new file mode 100644 index 00000000..cd596b2b --- /dev/null +++ b/src/components/mui/tables/components/create-delete-handler.js @@ -0,0 +1,31 @@ +import T from "i18n-react/dist/i18n-react"; +import showConfirmDialog from "../../showConfirmDialog"; + +const createDeleteHandler = ({ + onDelete, + getName = (item) => item.name, + deleteDialogTitle, + deleteDialogBody, + deleteDialogConfirmText, + confirmButtonColor +}) => async (item) => { + const isConfirmed = await showConfirmDialog({ + title: deleteDialogTitle || T.translate("general.are_you_sure"), + text: + typeof deleteDialogBody === "function" + ? deleteDialogBody(getName(item)) + : deleteDialogBody || + `${T.translate("general.row_remove_warning")} ${getName(item)}`, + type: "warning", + showCancelButton: true, + confirmButtonColor, + confirmButtonText: + deleteDialogConfirmText || T.translate("general.yes_delete") + }); + + if (isConfirmed) { + onDelete(item.id); + } +}; + +export default createDeleteHandler; diff --git a/src/components/mui/tables/components/row-actions-menu.js b/src/components/mui/tables/components/row-actions-menu.js new file mode 100644 index 00000000..494f753e --- /dev/null +++ b/src/components/mui/tables/components/row-actions-menu.js @@ -0,0 +1,67 @@ +/** + * 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 * as React from "react"; +import PropTypes from "prop-types"; +import IconButton from "@mui/material/IconButton"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; + +const RowActionsMenu = ({ actions }) => { + const [anchorEl, setAnchorEl] = React.useState(null); + + if (!actions.length) return null; + + return ( + <> + setAnchorEl(e.currentTarget)} + data-testid="row-actions-menu-trigger" + > + + + setAnchorEl(null)} + > + {actions.map((action) => ( + { + setAnchorEl(null); + action.onClick(); + }} + > + {action.label} + + ))} + + + ); +}; + +RowActionsMenu.propTypes = { + actions: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + disabled: PropTypes.bool + }) + ).isRequired +}; + +export default RowActionsMenu; diff --git a/src/components/mui/tables/components/scroll-fade-overlay.js b/src/components/mui/tables/components/scroll-fade-overlay.js new file mode 100644 index 00000000..b7b1b301 --- /dev/null +++ b/src/components/mui/tables/components/scroll-fade-overlay.js @@ -0,0 +1,33 @@ +import * as React from "react"; +import Box from "@mui/material/Box"; +import PropTypes from "prop-types"; +import { alpha } from "@mui/material/styles"; + +const FADE_WIDTH = 64; +const FADE_OPACITY = 0.85; + +const ScrollFadeOverlay = ({ side, visible }) => ( + { + const tint = alpha(theme.palette.background.paper, FADE_OPACITY); + return { + position: "absolute", + top: 0, + bottom: 0, + [side]: 0, + width: FADE_WIDTH, + pointerEvents: "none", + opacity: visible ? 1 : 0, + transition: "opacity 0.2s ease", + background: `linear-gradient(to ${side === "left" ? "right" : "left"}, ${tint}, transparent)` + }; + }} + /> +); + +ScrollFadeOverlay.propTypes = { + side: PropTypes.oneOf(["left", "right"]).isRequired, + visible: PropTypes.bool +}; + +export default ScrollFadeOverlay; diff --git a/src/components/mui/tables/components/sortable-header-content.js b/src/components/mui/tables/components/sortable-header-content.js new file mode 100644 index 00000000..dbf901dc --- /dev/null +++ b/src/components/mui/tables/components/sortable-header-content.js @@ -0,0 +1,40 @@ +import * as React from "react"; +import T from "i18n-react/dist/i18n-react"; +import Box from "@mui/material/Box"; +import TableSortLabel from "@mui/material/TableSortLabel"; +import PropTypes from "prop-types"; +import { visuallyHidden } from "@mui/utils"; + +const SortableHeaderContent = ({ col, sortCol, sortDir, onSort, sx }) => { + if (!col.sortable) { + return sx ? {col.header} : col.header; + } + + return ( + onSort(col.columnKey, sortDir * -1)} + sx={sx} + > + {col.header} + {sortCol === col.columnKey ? ( + + {sortDir === -1 + ? T.translate("mui_table.sorted_desc") + : T.translate("mui_table.sorted_asc")} + + ) : null} + + ); +}; + +SortableHeaderContent.propTypes = { + col: PropTypes.object.isRequired, + sortCol: PropTypes.string, + sortDir: PropTypes.oneOf([1, -1]), + onSort: PropTypes.func, + sx: PropTypes.object +}; + +export default SortableHeaderContent; diff --git a/src/components/mui/table/mui-table.module.less b/src/components/mui/tables/components/styles.module.less similarity index 100% rename from src/components/mui/table/mui-table.module.less rename to src/components/mui/tables/components/styles.module.less diff --git a/src/components/mui/table/table-cell-content.js b/src/components/mui/tables/components/table-cell-content.js similarity index 86% rename from src/components/mui/table/table-cell-content.js rename to src/components/mui/tables/components/table-cell-content.js index be032cf8..f2ae763f 100644 --- a/src/components/mui/table/table-cell-content.js +++ b/src/components/mui/tables/components/table-cell-content.js @@ -2,7 +2,7 @@ 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"; +import TruncateText from "../../truncate-text"; const contentStyle = { fontWeight: "normal", @@ -12,14 +12,14 @@ const contentStyle = { const TableCellContent = ({ row, col }) => { return ( - {isBoolean(row[col.columnKey]) ? ( + {col.render ? ( + col.render(row) + ) : isBoolean(row[col.columnKey]) ? ( row[col.columnKey] ? ( ) : ( ) - ) : col.render ? ( - col.render(row) ) : col.truncateText ? ( {row[col.columnKey]} diff --git a/src/components/mui/tables/components/table-shell.js b/src/components/mui/tables/components/table-shell.js new file mode 100644 index 00000000..f8176265 --- /dev/null +++ b/src/components/mui/tables/components/table-shell.js @@ -0,0 +1,58 @@ +import * as React from "react"; +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import TableContainer from "@mui/material/TableContainer"; +import PropTypes from "prop-types"; +import CustomTablePagination from "./CustomTablePagination"; +import useScrollFade from "./use-scroll-fade"; +import ScrollFadeOverlay from "./scroll-fade-overlay"; + +const TableShell = ({ + children, + totalRows, + perPage, + currentPage, + onPageChange, + onPerPageChange +}) => { + const { containerRef, showLeftFade, showRightFade } = useScrollFade(); + + return ( + + + + + {children} + + + + + + {perPage && currentPage && onPageChange && ( + + )} + + + ); +}; + +TableShell.propTypes = { + children: PropTypes.node, + totalRows: PropTypes.number, + perPage: PropTypes.number, + currentPage: PropTypes.number, + onPageChange: PropTypes.func, + onPerPageChange: PropTypes.func +}; + +export default TableShell; diff --git a/src/components/mui/tables/components/table-styles.js b/src/components/mui/tables/components/table-styles.js new file mode 100644 index 00000000..6f2975a8 --- /dev/null +++ b/src/components/mui/tables/components/table-styles.js @@ -0,0 +1,49 @@ +export const MIN_WIDTH_SMALL = 690; // sm breakpoint content width +export const MIN_WIDTH_WIDE = 910; // md breakpoint content width +export const SMALL_TABLE_MAX_COLUMNS = 3; // fewer than 4 data columns = small +export const DEFAULT_COLUMN_MIN_WIDTH = 40; // floor for columns with no explicit width + +export const ARCHIVED_CELL_SX = { + backgroundColor: "background.light", + color: "text.disabled" +}; + +export const ACTION_CELL_SX = { + p: 0, + textAlign: "center", + verticalAlign: "middle", + width: 40, + minWidth: 40, + maxWidth: 40 +}; + +export const getResponsiveTableSx = (dataColumnCount) => ({ + tableLayout: "auto", + minWidth: + dataColumnCount <= SMALL_TABLE_MAX_COLUMNS ? MIN_WIDTH_SMALL : MIN_WIDTH_WIDE +}); + +// same bucket used for minWidth: fewer columns can stay uncollapsed longer +export const getActionsMenuBreakpoint = (dataColumnCount) => + dataColumnCount <= SMALL_TABLE_MAX_COLUMNS ? "md" : "lg"; + +export const getColumnWidthSx = (col) => ({ + minWidth: col.width ?? DEFAULT_COLUMN_MIN_WIDTH, + ...(col.width && { + width: col.width, + maxWidth: col.width + }) +}); + +export const getArchivedRowSx = (row, disableProp) => + disableProp && row[disableProp] ? ARCHIVED_CELL_SX : {}; + +export const getActionCellSx = (row, disableProp, widthOverride) => ({ + ...ACTION_CELL_SX, + ...(widthOverride && { + width: widthOverride, + minWidth: widthOverride, + maxWidth: widthOverride + }), + ...getArchivedRowSx(row, disableProp) +}); diff --git a/src/components/mui/tables/components/use-scroll-fade.js b/src/components/mui/tables/components/use-scroll-fade.js new file mode 100644 index 00000000..482b685d --- /dev/null +++ b/src/components/mui/tables/components/use-scroll-fade.js @@ -0,0 +1,28 @@ +import * as React from "react"; + +const useScrollFade = () => { + const containerRef = React.useRef(null); + const [showLeftFade, setShowLeftFade] = React.useState(false); + const [showRightFade, setShowRightFade] = React.useState(false); + + const updateFades = React.useCallback(() => { + const el = containerRef.current; + if (!el) return; + setShowLeftFade(el.scrollLeft > 0); + setShowRightFade(Math.ceil(el.scrollLeft + el.clientWidth) < el.scrollWidth); + }, []); + + React.useEffect(() => { + const el = containerRef.current; + if (!el) return undefined; + + updateFades(); + el.addEventListener("scroll", updateFades); + + return () => el.removeEventListener("scroll", updateFades); + }, [updateFades]); + + return { containerRef, showLeftFade, showRightFade }; +}; + +export default useScrollFade; diff --git a/src/components/mui/tables/editable-table/index.js b/src/components/mui/tables/editable-table/index.js new file mode 100644 index 00000000..fa278a3c --- /dev/null +++ b/src/components/mui/tables/editable-table/index.js @@ -0,0 +1,390 @@ +/** + * 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 * as React from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import { IconButton, TextField } from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import styles from "../components/styles.module.less"; + +import TableCellContent from "../components/table-cell-content"; +import TableShell from "../components/table-shell"; +import createDeleteHandler from "../components/create-delete-handler"; +import SortableHeaderContent from "../components/sortable-header-content"; +import { + getResponsiveTableSx, + getColumnWidthSx, + getArchivedRowSx, + getActionCellSx, + ACTION_CELL_SX +} from "../components/table-styles"; + +const validateValue = (value, validation) => { + if (!validation) return { isValid: true }; + + // validate with yup schema + if ( + validation.schema && + typeof validation.schema.validateSync === "function" + ) { + try { + validation.schema.validateSync(value); + return { isValid: true, message: null }; + } catch (err) { + return { isValid: false, message: err.message }; + } + } + + return { isValid: true }; +}; + +// Updated component to handle editable cells with hover edit icon +const EditableCell = ({ value, isEditing, onBlur, validation }) => { + const [inputValue, setInputValue] = React.useState(value); + const [isHovering, setIsHovering] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + setInputValue(value); + setError(null); + }, [value]); + + const handleValidationAndSave = (newValue) => { + const { isValid, message } = validateValue(newValue, validation); + + if (isValid) { + setError(null); + onBlur(newValue, true); + } else { + setError(message); + } + }; + + const handleKeyDown = (e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleValidationAndSave(inputValue); + } + }; + + if (isEditing) { + return ( + { + setInputValue(e.target.value); + if (error) setError(null); + }} + onBlur={() => { + handleValidationAndSave(inputValue); + }} + onKeyDown={handleKeyDown} + size="small" + fullWidth + variant="standard" + error={!!error} + helperText={error} + /> + ); + } + + return ( + setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + {value} + {isHovering && ( + + )} + + ); +}; + +const MuiTableEditable = ({ + columns = [], + data = [], + totalRows, + perPage, + currentPage, + onPageChange, + onPerPageChange, + onSort, + options = { sortCol: "", sortDir: 1, disableProp: null }, + getName = (item) => item.name, + onEdit, + onArchive, + onDelete, + onCellChange, // New prop for handling cell value changes + deleteDialogBody, + tableSx = {} +}) => { + // State to track which cell is currently being edited + const [editingCell, setEditingCell] = React.useState(null); + + const { sortCol, sortDir } = options; + + const getCellSx = (row, baseSx = {}) => ({ + ...baseSx, + ...getArchivedRowSx(row, options.disableProp) + }); + + const handleDelete = createDeleteHandler({ + onDelete, + getName, + deleteDialogBody, + confirmButtonColor: "warning" + }); + + const isEditable = (col, row) => + typeof col.editable === "function" ? col.editable(row) : !!col.editable; + + // Handler for starting edit mode on a cell + const handleCellClick = (row, columnKey) => { + // Check if the column is editable + const column = columns.find((col) => col.columnKey === columnKey); + if (column && isEditable(column, row)) { + setEditingCell({ rowId: row.id, columnKey }); + } + }; + + // Handler for saving changes when editing is complete + const handleCellBlur = (rowId, columnKey, newValue, isValid) => { + if (onCellChange && isValid) { + onCellChange(rowId, columnKey, newValue); + } + setEditingCell(null); + }; + + return ( + + + {/* TABLE HEADER */} + + + {columns.map((col) => ( + + + + ))} + {onEdit && ( + + )} + {onArchive && ( + + )} + {onDelete && ( + + )} + + + {/* TABLE BODY */} + + {data.map((row) => ( + + {columns.map((col) => ( + handleCellClick(row, col.columnKey)} + sx={{ + ...getColumnWidthSx(col), + ...getCellSx(row, { + cursor: isEditable(col, row) ? "pointer" : "default", + padding: isEditable(col, row) ? "8px 16px" : undefined, + ...(col.cellSx || {}) + }) + }} + > + {isEditable(col, row) ? ( + + handleCellBlur( + row.id, + col.columnKey, + newValue, + isValid + ) + } + validation={col.validation} + /> + ) : ( + + )} + + ))} + {onEdit && ( + + onEdit(row)} + size="small" + aria-label={T.translate("general.edit")} + > + + + + )} + {onArchive && ( + + + + )} + {onDelete && ( + + handleDelete(row)} + size="small" + aria-label={T.translate("general.delete")} + > + + + + )} + + ))} + +
+
+ ); +}; + +MuiTableEditable.propTypes = { + columns: PropTypes.arrayOf( + PropTypes.shape({ + columnKey: PropTypes.string.isRequired, + header: PropTypes.node, + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + align: PropTypes.string, + sortable: PropTypes.bool, + editable: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), + validation: PropTypes.shape({ schema: PropTypes.object }), + render: PropTypes.func + }) + ), + data: PropTypes.arrayOf(PropTypes.object), + totalRows: PropTypes.number, + // Pagination only renders when all three are provided — see line 366. + perPage: PropTypes.number, + currentPage: PropTypes.number, + onPageChange: PropTypes.func, + onPerPageChange: PropTypes.func, + onSort: PropTypes.func, + options: PropTypes.shape({ + sortCol: PropTypes.string, + sortDir: PropTypes.oneOf([1, -1]), + disableProp: PropTypes.string + }), + getName: PropTypes.func, + onEdit: PropTypes.func, + onArchive: PropTypes.func, + onDelete: PropTypes.func, + onCellChange: PropTypes.func, + deleteDialogBody: PropTypes.func +}; + +export default MuiTableEditable; diff --git a/src/components/mui/table/extra-rows/DiscountRow.jsx b/src/components/mui/tables/extra-rows/DiscountRow.jsx similarity index 100% rename from src/components/mui/table/extra-rows/DiscountRow.jsx rename to src/components/mui/tables/extra-rows/DiscountRow.jsx diff --git a/src/components/mui/table/extra-rows/FeeRow.jsx b/src/components/mui/tables/extra-rows/FeeRow.jsx similarity index 100% rename from src/components/mui/table/extra-rows/FeeRow.jsx rename to src/components/mui/tables/extra-rows/FeeRow.jsx diff --git a/src/components/mui/table/extra-rows/NotesRow.jsx b/src/components/mui/tables/extra-rows/NotesRow.jsx similarity index 100% rename from src/components/mui/table/extra-rows/NotesRow.jsx rename to src/components/mui/tables/extra-rows/NotesRow.jsx diff --git a/src/components/mui/table/extra-rows/PaymentRow.jsx b/src/components/mui/tables/extra-rows/PaymentRow.jsx similarity index 100% rename from src/components/mui/table/extra-rows/PaymentRow.jsx rename to src/components/mui/tables/extra-rows/PaymentRow.jsx diff --git a/src/components/mui/table/extra-rows/RefundRow.jsx b/src/components/mui/tables/extra-rows/RefundRow.jsx similarity index 100% rename from src/components/mui/table/extra-rows/RefundRow.jsx rename to src/components/mui/tables/extra-rows/RefundRow.jsx diff --git a/src/components/mui/table/extra-rows/TotalRow.jsx b/src/components/mui/tables/extra-rows/TotalRow.jsx similarity index 100% rename from src/components/mui/table/extra-rows/TotalRow.jsx rename to src/components/mui/tables/extra-rows/TotalRow.jsx diff --git a/src/components/mui/table/extra-rows/index.js b/src/components/mui/tables/extra-rows/index.js similarity index 100% rename from src/components/mui/table/extra-rows/index.js rename to src/components/mui/tables/extra-rows/index.js diff --git a/src/components/mui/tables/mui-table/index.js b/src/components/mui/tables/mui-table/index.js new file mode 100644 index 00000000..31fb69eb --- /dev/null +++ b/src/components/mui/tables/mui-table/index.js @@ -0,0 +1,351 @@ +/** + * 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 * as React from "react"; +import T from "i18n-react/dist/i18n-react"; +import { + Box, + IconButton, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import ArchiveIcon from "@mui/icons-material/Archive"; +import UnarchiveIcon from "@mui/icons-material/Unarchive"; +import styles from "../components/styles.module.less"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import PropTypes from "prop-types"; +import TableCellContent from "../components/table-cell-content"; +import TableShell from "../components/table-shell"; +import createDeleteHandler from "../components/create-delete-handler"; +import SortableHeaderContent from "../components/sortable-header-content"; +import RowActionsMenu from "../components/row-actions-menu"; +import { + getResponsiveTableSx, + getColumnWidthSx, + getArchivedRowSx, + getActionCellSx, + getActionsMenuBreakpoint, + ACTION_CELL_SX +} from "../components/table-styles"; + +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, + tableSx = {} +}) => { + const activeActionsCount = + (onEdit ? 1 : 0) + (onDelete ? 1 : 0) + (onArchive ? 1 : 0) + (onSelect ? 1 : 0); + const collapseActions = activeActionsCount >= 2; + const collapseBreakpoint = getActionsMenuBreakpoint(columns.length); + + const totalColumnsCount = + columns.length + activeActionsCount + (collapseActions ? 1 : 0); + + const {sortCol, sortDir} = options; + + const getHeaderSx = (col) => ({ + ...getColumnWidthSx(col), + ...(col.headSx || {}) + }); + + // individual action cells hide below collapseBreakpoint once actions collapse into a menu + const getIndividualActionSx = () => + collapseActions ? { display: { xs: "none", [collapseBreakpoint]: "table-cell" } } : {}; + + const menuCellSx = { display: { xs: "table-cell", [collapseBreakpoint]: "none" } }; + + const getRowActions = (row) => + [ + onEdit && { + label: T.translate("general.edit"), + onClick: () => onEdit(row), + disabled: !!(options.disableProp && row[options.disableProp]) + }, + onArchive && { + label: row.is_archived + ? T.translate("general.unarchive") + : T.translate("general.archive"), + onClick: () => onArchive(row), + disabled: !!( + options.disableProp && + options.disableProp !== "is_archived" && + row[options.disableProp] + ) + }, + onDelete && + canDelete(row) && { + label: T.translate("general.delete"), + onClick: () => handleDelete(row), + disabled: !!(options.disableProp && row[options.disableProp]) + }, + onSelect && { + label: "View", + onClick: () => onSelect(row), + disabled: !!(options.disableProp && row[options.disableProp]) + } + ].filter(Boolean); + + const getCellSx = (row, col) => ({ + ...getColumnWidthSx(col), + ...(col.cellSx || {}), + ...getArchivedRowSx(row, options.disableProp) + }); + + const handleDelete = createDeleteHandler({ + onDelete, + getName, + deleteDialogTitle, + deleteDialogBody, + deleteDialogConfirmText, + confirmButtonColor: confirmButtonColor || "error" + }); + + return ( + + + {/* TABLE HEADER */} + + + {columns.map((col) => ( + + + + ))} + {onEdit && } + {onArchive && } + {onDelete && } + {onSelect && } + {collapseActions && } + + + + {/* TABLE BODY */} + + {data.map((row) => ( + + {/* Main content columns */} + {columns.map((col) => ( + + + + ))} + {/* Edit column */} + {onEdit && ( + + + + onEdit(row)} + sx={{ padding: 0 }} + data-testid="action-edit" + disabled={options.disableProp && row[options.disableProp]} + > + + + + + + )} + {onArchive && ( + + + + onArchive(row)} + sx={{ padding: 0 }} + data-testid="action-archive" + // bypass disabled if disableProp is "is_archived" + disabled={ + options.disableProp && + options.disableProp !== "is_archived" && + row[options.disableProp] + } + > + {row.is_archived ? ( + + ) : ( + + )} + + + + + )} + {/* Delete column */} + {onDelete && ( + + {canDelete(row) && ( + + + handleDelete(row)} + data-testid="action-delete" + sx={{ padding: 0 }} + disabled={options.disableProp && row[options.disableProp]} + > + + + + + )} + + )} + {onSelect && ( + + + + onSelect(row)} + data-testid="action-select" + sx={{ padding: 0 }} + disabled={options.disableProp && row[options.disableProp]} + > + + + + + + )} + {collapseActions && ( + + + + )} + + ))} + {/* Here we inject extra rows passed as children */} + {children} + {data.length === 0 && ( + + + {T.translate("mui_table.no_items")} + + + )} + +
+
+ ); +}; + +MuiTable.propTypes = { + columns: PropTypes.array, + data: PropTypes.array, + children: PropTypes.node, + totalRows: PropTypes.number, + perPage: PropTypes.number, + currentPage: PropTypes.number, + onPageChange: PropTypes.func, + onPerPageChange: PropTypes.func, + onSort: PropTypes.func, + options: PropTypes.object, + getName: PropTypes.func, + onEdit: PropTypes.func, + onArchive: PropTypes.func, + onDelete: PropTypes.func, + onSelect: PropTypes.func, + canDelete: PropTypes.func, + deleteDialogTitle: PropTypes.string, + deleteDialogBody: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + deleteDialogConfirmText: PropTypes.string, + confirmButtonColor: PropTypes.string, + tableSx: PropTypes.object +}; + +export default MuiTable; diff --git a/src/components/mui/tables/sortable-table/index.js b/src/components/mui/tables/sortable-table/index.js new file mode 100644 index 00000000..6667d9ea --- /dev/null +++ b/src/components/mui/tables/sortable-table/index.js @@ -0,0 +1,241 @@ +/** + * 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 * as React from "react"; +import T from "i18n-react/dist/i18n-react"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore"; +import { IconButton } from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd"; + +import styles from "../components/styles.module.less"; + +import TableCellContent from "../components/table-cell-content"; +import TableShell from "../components/table-shell"; +import createDeleteHandler from "../components/create-delete-handler"; +import SortableHeaderContent from "../components/sortable-header-content"; +import { + getResponsiveTableSx, + getColumnWidthSx, + getActionCellSx, + ACTION_CELL_SX +} from "../components/table-styles"; + +const MuiTableSortable = ({ + columns = [], + data = [], + totalRows, + perPage, + currentPage, + onPageChange, + onPerPageChange, + onSort, + options = { sortCol: "", sortDir: 1 }, + getName = (item) => item.name, + onEdit, + onDelete, + deleteDialogTitle = null, + deleteDialogBody = null, + onReorder, + idKey = "id", + updateOrderKey = "order", + tableSx = {} +}) => { + const { sortCol, sortDir } = options; + + const handleDragEnd = (result) => { + if (!result.destination || result.source.index === result.destination.index) + return; + + const reordered = [...data]; + const [movedItem] = reordered.splice(result.source.index, 1); + reordered.splice(result.destination.index, 0, movedItem); + + // change value based on updateOrderKey + if (updateOrderKey) { + reordered.forEach((item, idx) => { + item[updateOrderKey] = idx + 1; + }); + } + + const movedItemId = movedItem.id; + const newOrder = reordered.find( + (item) => item[idKey || "id"] === movedItemId + )?.[updateOrderKey]; + + onReorder?.(reordered, movedItemId, newOrder); + }; + + const handleDelete = createDeleteHandler({ + onDelete, + getName, + deleteDialogTitle, + deleteDialogBody, + confirmButtonColor: "#DD6B55" + }); + + return ( + + + {/* TABLE HEADER */} + + + {columns.map((col) => ( + + + + ))} + {onEdit && } + {onDelete && } + {onReorder && } + + + + {/* TABLE BODY */} + + + {(droppableProvided) => ( + + {data.map((row, rowIndex) => ( + + {(provided, snapshot) => ( + + {/* Main content columns */} + {columns.map((col) => ( + + + + ))} + {/* Edit column */} + {onEdit && ( + + onEdit(row)} + sx={{ padding: 0 }} + > + + + + )} + {/* Delete column */} + {onDelete && ( + + handleDelete(row)} + sx={{ padding: 0 }} + > + + + + )} + {/* Re order column */} + {onReorder && ( + + + + + + )} + + )} + + ))} + {droppableProvided.placeholder} + {data.length === 0 && ( + + + {T.translate("mui_table.no_items")} + + + )} + + )} + + +
+
+ ); +}; + +export default MuiTableSortable; diff --git a/webpack.common.js b/webpack.common.js index b735486f..80ee3677 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -104,12 +104,12 @@ module.exports = { 'components/mui/notes-modal': './src/components/mui/NotesModal/index.js', 'components/mui/snackbar-notification': './src/components/mui/SnackbarNotification/index.js', 'components/mui/infinite-table': './src/components/mui/infinite-table/index.js', - 'components/mui/editable-table': './src/components/mui/editable-table/mui-table-editable.js', - 'components/mui/sortable-table': './src/components/mui/sortable-table/mui-table-sortable.js', + 'components/mui/editable-table': './src/components/mui/tables/editable-table/index.js', + 'components/mui/sortable-table': './src/components/mui/tables/sortable-table/index.js', 'components/mui/bulk-edit-table': './src/components/mui/BulkEditTable/index.js', - 'components/mui/table': './src/components/mui/table/mui-table.js', - 'components/mui/table/custom-table-pagination': './src/components/mui/table/CustomTablePagination.js', - 'components/mui/table/extra-rows': './src/components/mui/table/extra-rows/index.js', + 'components/mui/table': './src/components/mui/tables/mui-table/index.js', + 'components/mui/table/custom-table-pagination': './src/components/mui/tables/components/CustomTablePagination.js', + 'components/mui/table/extra-rows': './src/components/mui/tables/extra-rows/index.js', 'components/mui/formik-inputs/additional-input': './src/components/mui/formik-inputs/additional-input/additional-input.js', 'components/mui/formik-inputs/additional-input-list': './src/components/mui/formik-inputs/additional-input/additional-input-list.js', 'components/mui/formik-inputs/async-select': './src/components/mui/formik-inputs/mui-formik-async-select.js',