diff --git a/package.json b/package.json index 5d05a494..e028b205 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.60", + "version": "5.0.61-beta.7", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { diff --git a/src/components/mui/BulkEditTable/BulkEditTable.js b/src/components/mui/BulkEditTable/BulkEditTable.js index 604d88fa..925c6d4b 100644 --- a/src/components/mui/BulkEditTable/BulkEditTable.js +++ b/src/components/mui/BulkEditTable/BulkEditTable.js @@ -29,7 +29,12 @@ import Row from "./components/Row"; import useRowSelection from "./hooks/useRowSelection"; import styles from "./BulkEditTable.module.less"; import CustomTablePagination from "../tables/components/CustomTablePagination"; +import parsePaginationPosition from "../tables/components/pagination-position"; import showConfirmDialog from "../showConfirmDialog"; +import { + RESPONSIVE_TABLE_SX, + getActionsMenuBreakpoint +} from "../tables/components/table-styles"; const BulkEditTable = ({ options, @@ -42,6 +47,8 @@ const BulkEditTable = ({ currentPage, onPageChange, onPerPageChange, + paginationPosition, + pageSliderVisible, idKey, onEdit, onDelete, @@ -64,6 +71,9 @@ const BulkEditTable = ({ reset } = useRowSelection(idKey); + const collapseActions = (onEdit ? 1 : 0) + (onDelete ? 1 : 0) >= 2; + const actionsBreakpoint = getActionsMenuBreakpoint(columns.length); + const dataIds = data.map((row) => row[idKey]).join(","); // reset selection/edit state whenever the set of rows shown changes @@ -108,22 +118,50 @@ const BulkEditTable = ({ } }; + const showPagination = !!(perPage && currentPage && onPageChange); + const { showTop, showBottom } = parsePaginationPosition(paginationPosition); + const renderPagination = (showRange) => ( + + ); + return ( - 0} - onEdit={enterEditMode} - onApply={handleUpdateEvents} - onCancel={cancel} - /> + + + {showPagination && showTop && ( + {renderPagination(false)} + )} + - + {columns.map((col, i) => { const sortable = !!col.sortable; - const colWidth = col.width ?? ""; return ( {col.header ?? col.label ?? col.value} @@ -187,20 +224,14 @@ const BulkEditTable = ({ columns={columns} onEdit={onEdit} onDelete={onDelete ? handleDelete : null} + collapseActions={collapseActions} + actionsBreakpoint={actionsBreakpoint} /> ))} - {perPage && currentPage && onPageChange && ( - - )} + {showPagination && showBottom && renderPagination(true)} ); @@ -218,6 +249,8 @@ BulkEditTable.propTypes = { currentPage: PropTypes.number, onPageChange: PropTypes.func, onPerPageChange: PropTypes.func, + paginationPosition: PropTypes.string, + pageSliderVisible: PropTypes.bool, onEdit: PropTypes.func, onDelete: PropTypes.func, getName: PropTypes.func, diff --git a/src/components/mui/BulkEditTable/BulkEditTable.module.less b/src/components/mui/BulkEditTable/BulkEditTable.module.less index aa9a182f..52b2635a 100644 --- a/src/components/mui/BulkEditTable/BulkEditTable.module.less +++ b/src/components/mui/BulkEditTable/BulkEditTable.module.less @@ -17,14 +17,8 @@ position: relative; td { - max-width: 150px; - text-overflow: ellipsis; overflow-wrap: break-word; vertical-align: middle; - - &.dataColumn { - min-width: 150px; - } } // shared by header (th) and body (td) cells so the checkbox/action columns diff --git a/src/components/mui/BulkEditTable/__tests__/BulkEditTable.test.js b/src/components/mui/BulkEditTable/__tests__/BulkEditTable.test.js index 335f874c..c9401ead 100644 --- a/src/components/mui/BulkEditTable/__tests__/BulkEditTable.test.js +++ b/src/components/mui/BulkEditTable/__tests__/BulkEditTable.test.js @@ -57,7 +57,7 @@ describe("BulkEditTable", () => { const checkboxes = screen.getAllByRole("checkbox"); await user.click(checkboxes[1]); - await user.click(screen.getByText("bulk_edit_table.edit_selected")); + await user.click(screen.getByText(/^bulk_edit_table\.edit_selected/)); await act(async () => { await user.click(screen.getByText("bulk_edit_table.apply_changes")); }); @@ -86,7 +86,7 @@ describe("BulkEditTable", () => { // select row 1 and enter edit mode await user.click(checkboxes[1]); - await user.click(screen.getByText("bulk_edit_table.edit_selected")); + await user.click(screen.getByText(/^bulk_edit_table\.edit_selected/)); // type an edit into row 1's editable title cell fireEvent.change(screen.getByRole("textbox"), { diff --git a/src/components/mui/BulkEditTable/components/Heading.js b/src/components/mui/BulkEditTable/components/Heading.js index dd808c81..ef001df4 100644 --- a/src/components/mui/BulkEditTable/components/Heading.js +++ b/src/components/mui/BulkEditTable/components/Heading.js @@ -18,6 +18,7 @@ import Box from "@mui/material/Box"; import TableCell from "@mui/material/TableCell"; import TableSortLabel from "@mui/material/TableSortLabel"; import { visuallyHidden } from "@mui/utils"; +import { getColumnWidthSx } from "../../tables/components/table-styles"; const Heading = (props) => { const { @@ -27,7 +28,7 @@ const Heading = (props) => { onSort, columnIndex, columnKey, - width, + col, children } = props; @@ -37,7 +38,7 @@ const Heading = (props) => { onSort(columnIndex, columnKey, sortDir ? sortDir * -1 : 1); }; - const headerSx = width ? { width, minWidth: width, maxWidth: width } : {}; + const headerSx = getColumnWidthSx(col); if (!sortable || editEnabled) { return {children}; @@ -70,7 +71,7 @@ Heading.propTypes = { columnIndex: PropTypes.number, columnKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), sortable: PropTypes.bool, - width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + col: PropTypes.object.isRequired, children: PropTypes.node }; diff --git a/src/components/mui/BulkEditTable/components/Row.js b/src/components/mui/BulkEditTable/components/Row.js index dcd5ee7d..cae9d2f4 100644 --- a/src/components/mui/BulkEditTable/components/Row.js +++ b/src/components/mui/BulkEditTable/components/Row.js @@ -20,15 +20,17 @@ import Checkbox from "@mui/material/Checkbox"; import IconButton from "@mui/material/IconButton"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; +import T from "i18n-react/dist/i18n-react"; import Cell from "./Cell"; +import RowActionsMenu from "../../tables/components/row-actions-menu"; +import { getColumnWidthSx } from "../../tables/components/table-styles"; import styles from "../BulkEditTable.module.less"; // the 250px min-width while editing comes from the .bulkEditCol class -// (applied via className below) so it isn't duplicated here -const getCellStyle = (col) => ({ - ...(col.width - ? { width: col.width, minWidth: col.width, maxWidth: col.width } - : {}), +// (applied via className below), so it overrides the adaptive width here +const getCellSx = (col, isEditingRow) => ({ + ...getColumnWidthSx(col), + ...(isEditingRow && col.editableField ? { minWidth: 250 } : {}), ...col.customStyle }); @@ -43,11 +45,24 @@ const Row = (props) => { onFieldChange, onEdit, onDelete, - idKey + idKey, + collapseActions, + actionsBreakpoint } = props; const isEditingRow = isSelected && editEnabled; + const rowActions = [ + onEdit && { + label: T.translate("general.edit"), + onClick: () => onEdit(row) + }, + onDelete && { + label: T.translate("general.delete"), + onClick: () => onDelete(row) + } + ].filter(Boolean); + const onRowChange = (ev) => { const { value, id } = ev.target; onFieldChange(id, value); @@ -72,13 +87,8 @@ const Row = (props) => { {columns.map((col) => ( { className={`${styles.actionColumn} ${styles.dottedBorderLeft}`} sx={{ backgroundColor: "#fff" }} > - + {onEdit && ( { )} + {collapseActions && ( + + + + )} )} @@ -133,13 +156,17 @@ Row.propTypes = { onFieldChange: PropTypes.func, onEdit: PropTypes.func, onDelete: PropTypes.func, - idKey: PropTypes.string + idKey: PropTypes.string, + collapseActions: PropTypes.bool, + actionsBreakpoint: PropTypes.string }; Row.defaultProps = { idKey: "id", onEdit: null, - onDelete: null + onDelete: null, + collapseActions: false, + actionsBreakpoint: "md" }; export default Row; diff --git a/src/components/mui/BulkEditTable/components/Toolbar.js b/src/components/mui/BulkEditTable/components/Toolbar.js index 39d40485..38e0907d 100644 --- a/src/components/mui/BulkEditTable/components/Toolbar.js +++ b/src/components/mui/BulkEditTable/components/Toolbar.js @@ -17,20 +17,34 @@ import T from "i18n-react/dist/i18n-react"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -const Toolbar = ({ editEnabled, hasSelection, onEdit, onApply, onCancel }) => ( - +const Toolbar = ({ editEnabled, selectedCount, onEdit, onApply, onCancel }) => ( + {editEnabled ? ( <> - + {T.translate("bulk_edit_table.apply_changes")} - + {T.translate("general.cancel")} > ) : ( - + {T.translate("bulk_edit_table.edit_selected")} + {selectedCount > 0 ? ` (${selectedCount})` : ""} )} @@ -38,10 +52,14 @@ const Toolbar = ({ editEnabled, hasSelection, onEdit, onApply, onCancel }) => ( Toolbar.propTypes = { editEnabled: PropTypes.bool, - hasSelection: PropTypes.bool, + selectedCount: PropTypes.number, onEdit: PropTypes.func, onApply: PropTypes.func, onCancel: PropTypes.func }; +Toolbar.defaultProps = { + selectedCount: 0 +}; + export default Toolbar; diff --git a/src/components/mui/__tests__/mui-table-custom-pagination.test.js b/src/components/mui/__tests__/mui-table-custom-pagination.test.js new file mode 100644 index 00000000..59dccf72 --- /dev/null +++ b/src/components/mui/__tests__/mui-table-custom-pagination.test.js @@ -0,0 +1,87 @@ +/** + * 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. + * */ + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import CustomTablePagination from "../tables/components/CustomTablePagination"; + +const setup = (overrides = {}) => { + const props = { + totalRows: 100, + perPage: 10, + currentPage: 3, + onPageChange: jest.fn(), + onPerPageChange: jest.fn(), + ...overrides + }; + render(); + return props; +}; + +describe("CustomTablePagination", () => { + test("shows the page label and no rows-per-page select without onPerPageChange", () => { + setup({ onPerPageChange: undefined }); + expect(screen.getByText("mui_table.page_of")).toBeInTheDocument(); + expect(screen.queryByLabelText("mui_table.rows_per_page")).not.toBeInTheDocument(); + }); + + test("shows the rows-per-page select when onPerPageChange is provided and calls it", async () => { + const { onPerPageChange } = setup(); + await userEvent.click(screen.getByLabelText("mui_table.rows_per_page")); + await userEvent.click(screen.getByRole("option", { name: "20" })); + expect(onPerPageChange).toHaveBeenCalledWith(20); + }); + + test("prev/next buttons call onPageChange and disable at the boundaries", async () => { + const onPageChange = jest.fn(); + setup({ onPageChange, currentPage: 1, totalRows: 20, perPage: 10 }); + + expect(screen.getByRole("button", { name: "mui_table.previous_page" })).toBeDisabled(); + + await userEvent.click(screen.getByRole("button", { name: "mui_table.next_page" })); + expect(onPageChange).toHaveBeenCalledWith(2); + }); + + test("disables the next button on the last page", () => { + setup({ currentPage: 10, totalRows: 100, perPage: 10 }); + expect(screen.getByRole("button", { name: "mui_table.next_page" })).toBeDisabled(); + }); + + test("clicking the pill reveals a slider bounded to the page count", async () => { + setup({ currentPage: 3, totalRows: 100, perPage: 10 }); + await userEvent.click(screen.getByText("mui_table.page_of")); + + const slider = screen.getByRole("slider"); + expect(slider).toHaveAttribute("aria-valuemin", "1"); + expect(slider).toHaveAttribute("aria-valuemax", "10"); + expect(slider).toHaveAttribute("aria-valuenow", "3"); + }); + + test("moving the slider commits the new page via onPageChange", async () => { + const onPageChange = jest.fn(); + setup({ onPageChange, currentPage: 3, totalRows: 100, perPage: 10 }); + await userEvent.click(screen.getByText("mui_table.page_of")); + + const slider = screen.getByRole("slider"); + fireEvent.keyDown(slider, { key: "ArrowRight" }); + + expect(onPageChange).toHaveBeenCalledWith(4); + }); +}); diff --git a/src/components/mui/__tests__/mui-table-editable.test.js b/src/components/mui/__tests__/mui-table-editable.test.js index 562c15a6..8dd3f6a0 100644 --- a/src/components/mui/__tests__/mui-table-editable.test.js +++ b/src/components/mui/__tests__/mui-table-editable.test.js @@ -59,53 +59,6 @@ jest.mock("@mui/material/TableCell", () => { }; }); -// TablePagination shim -jest.mock("@mui/material/TablePagination", () => { - const React = require("react"); - return { - __esModule: true, - default: function TablePaginationMock(props) { - const { - count, - rowsPerPage, - page, - rowsPerPageOptions, - onPageChange, - onRowsPerPageChange, - labelRowsPerPage - } = props; - - return ( - - count:{count} - rowsPerPage:{rowsPerPage} - page:{page} - label:{labelRowsPerPage} - - options:{rowsPerPageOptions && rowsPerPageOptions.join(",")} - - onPageChange({}, page + 1)} - aria-label="next-page" - > - next - - - onRowsPerPageChange({ - target: { value: rowsPerPageOptions?.[0] ?? 10 } - }) - } - aria-label="change-rows" - > - change-rows - - - ); - } - }; -}); - // TableSortLabel shim -> renders an actual jest.mock("@mui/material/TableSortLabel", () => { const React = require("react"); @@ -276,37 +229,31 @@ describe("MuiTableEditable", () => { test("pagination next -> onPageChange(2) when starting at page 1", async () => { const user = userEvent.setup(); - const { onPageChange } = setup({ currentPage: 1 }); - const next = within(screen.getByTestId("pagination")).getByRole("button", { - name: "next-page" - }); - await user.click(next); + const { onPageChange } = setup({ currentPage: 1, totalRows: 20, perPage: 10 }); + await user.click(screen.getAllByRole("button", { name: "mui_table.next_page" })[0]); expect(onPageChange).toHaveBeenCalledWith(2); }); test("change rows per page triggers onPerPageChange", async () => { const user = userEvent.setup(); const { onPerPageChange } = setup({ perPage: 25 }); - const change = within(screen.getByTestId("pagination")).getByRole( - "button", - { name: "change-rows" } - ); - await user.click(change); - expect(onPerPageChange).toHaveBeenCalledWith(expect.any(Number)); + await user.click(screen.getAllByLabelText("mui_table.rows_per_page")[0]); + await user.click(screen.getAllByRole("option", { name: "10" })[0]); + expect(onPerPageChange).toHaveBeenCalledWith(10); }); - test("uses totalRows when provided", () => { - setup({ totalRows: 123 }); - expect( - within(screen.getByTestId("pagination")).getByText("count:123") - ).toBeInTheDocument(); + test("uses totalRows when provided", async () => { + const user = userEvent.setup(); + setup({ totalRows: 123, perPage: 10 }); + await user.click(screen.getAllByText("mui_table.page_of")[0]); + expect(screen.getAllByRole("slider")[0]).toHaveAttribute("aria-valuemax", "13"); }); - test("falls back to data.length when totalRows missing", () => { - setup({ totalRows: undefined, data: [{ id: 1 }, { id: 2 }, { id: 3 }] }); - expect( - within(screen.getByTestId("pagination")).getByText("count:3") - ).toBeInTheDocument(); + test("falls back to data.length when totalRows missing", async () => { + const user = userEvent.setup(); + setup({ totalRows: undefined, data: [{ id: 1 }, { id: 2 }, { id: 3 }], perPage: 10 }); + await user.click(screen.getAllByText("mui_table.page_of")[0]); + expect(screen.getAllByRole("slider")[0]).toHaveAttribute("aria-valuemax", "1"); }); test("sort click triggers onSort with flipped dir", async () => { diff --git a/src/components/mui/__tests__/mui-table-sortable-v2.test.js b/src/components/mui/__tests__/mui-table-sortable-v2.test.js index 4c84d8f2..f1a7ae39 100644 --- a/src/components/mui/__tests__/mui-table-sortable-v2.test.js +++ b/src/components/mui/__tests__/mui-table-sortable-v2.test.js @@ -57,34 +57,6 @@ jest.mock("@dnd-kit/utilities", () => ({ CSS: { Transform: { toString: () => "" } } })); -jest.mock("@mui/material/TablePagination", () => { - const React = require("react"); - return { - __esModule: true, - default: ({ count, page, onPageChange, onRowsPerPageChange, rowsPerPageOptions }) => ( - - count:{count} - onPageChange({}, page + 1)} - aria-label="next-page" - > - next - - - onRowsPerPageChange({ - target: { value: rowsPerPageOptions?.[0] ?? 10 } - }) - } - aria-label="change-rows" - > - change-rows - - - ) - }; -}); - import React from "react"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -148,7 +120,8 @@ describe("MuiTableSortableV2", () => { test("calls onEdit when edit button is clicked", async () => { const onEdit = jest.fn(); setup({ onEdit }); - const buttons = screen.getAllByRole("button"); + // scoped to the table itself, since pagination (top+bottom) also renders buttons + const buttons = within(screen.getByRole("table")).getAllByRole("button"); // buttons[0] is the sort label button for the sortable "Name" column; // buttons[1] is the first edit button (row 1) await userEvent.click(buttons[1]); @@ -159,7 +132,7 @@ describe("MuiTableSortableV2", () => { const onDelete = jest.fn(); showConfirmDialog.mockResolvedValueOnce(true); setup({ onDelete }); - const buttons = screen.getAllByRole("button"); + const buttons = within(screen.getByRole("table")).getAllByRole("button"); // buttons[0] is the sort label button; buttons[1] is the first delete button (row 1) await userEvent.click(buttons[1]); await new Promise((r) => setTimeout(r, 0)); @@ -167,18 +140,16 @@ describe("MuiTableSortableV2", () => { expect(onDelete).toHaveBeenCalledWith(1); }); - test("renders pagination", () => { + test("renders pagination (top and bottom)", () => { setup(); - expect(screen.getByTestId("pagination")).toBeInTheDocument(); + expect(screen.getAllByText("mui_table.page_of")).toHaveLength(2); }); test("calls onPageChange when next page is clicked", async () => { const onPageChange = jest.fn(); - setup({ onPageChange, currentPage: 1 }); + setup({ onPageChange, currentPage: 1, totalRows: 20, perPage: 10 }); await userEvent.click( - within(screen.getByTestId("pagination")).getByRole("button", { - name: "next-page" - }) + screen.getAllByRole("button", { name: "mui_table.next_page" })[0] ); expect(onPageChange).toHaveBeenCalledWith(2); }); @@ -268,7 +239,7 @@ describe("MuiTableSortableV2", () => { 2 ); - const buttons = screen.getAllByRole("button"); + const buttons = within(screen.getByRole("table")).getAllByRole("button"); // buttons[0] is the sort label button; buttons[1] is the first delete button (row 1) await userEvent.click(buttons[1]); await new Promise((r) => setTimeout(r, 0)); diff --git a/src/components/mui/__tests__/mui-table-sortable.test.js b/src/components/mui/__tests__/mui-table-sortable.test.js index 70d5a49c..fa5ff099 100644 --- a/src/components/mui/__tests__/mui-table-sortable.test.js +++ b/src/components/mui/__tests__/mui-table-sortable.test.js @@ -38,34 +38,6 @@ jest.mock("react-beautiful-dnd", () => { }; }); -jest.mock("@mui/material/TablePagination", () => { - const React = require("react"); - return { - __esModule: true, - default: ({ count, page, onPageChange, onRowsPerPageChange, rowsPerPageOptions }) => ( - - count:{count} - onPageChange({}, page + 1)} - aria-label="next-page" - > - next - - - onRowsPerPageChange({ - target: { value: rowsPerPageOptions?.[0] ?? 10 } - }) - } - aria-label="change-rows" - > - change-rows - - - ) - }; -}); - import React from "react"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -129,7 +101,8 @@ describe("MuiTableSortable", () => { test("calls onEdit when edit button is clicked", async () => { const onEdit = jest.fn(); setup({ onEdit }); - const buttons = screen.getAllByRole("button"); + // scoped to the table itself, since pagination (top+bottom) also renders buttons + const buttons = within(screen.getByRole("table")).getAllByRole("button"); // buttons[0] is the sort label button for the sortable "Name" column; // buttons[1] is the first edit button (row 1) await userEvent.click(buttons[1]); @@ -140,7 +113,7 @@ describe("MuiTableSortable", () => { const onDelete = jest.fn(); showConfirmDialog.mockResolvedValueOnce(true); setup({ onDelete }); - const buttons = screen.getAllByRole("button"); + const buttons = within(screen.getByRole("table")).getAllByRole("button"); // buttons[0] is the sort label button; buttons[1] is the first delete button (row 1) await userEvent.click(buttons[1]); await new Promise((r) => setTimeout(r, 0)); @@ -148,18 +121,16 @@ describe("MuiTableSortable", () => { expect(onDelete).toHaveBeenCalledWith(1); }); - test("renders pagination", () => { + test("renders pagination (top and bottom)", () => { setup(); - expect(screen.getByTestId("pagination")).toBeInTheDocument(); + expect(screen.getAllByText("mui_table.page_of")).toHaveLength(2); }); test("calls onPageChange when next page is clicked", async () => { const onPageChange = jest.fn(); - setup({ onPageChange, currentPage: 1 }); + setup({ onPageChange, currentPage: 1, totalRows: 20, perPage: 10 }); await userEvent.click( - within(screen.getByTestId("pagination")).getByRole("button", { - name: "next-page" - }) + screen.getAllByRole("button", { name: "mui_table.next_page" })[0] ); expect(onPageChange).toHaveBeenCalledWith(2); }); diff --git a/src/components/mui/__tests__/mui-table.test.js b/src/components/mui/__tests__/mui-table.test.js index 904cedbe..e60e87f0 100644 --- a/src/components/mui/__tests__/mui-table.test.js +++ b/src/components/mui/__tests__/mui-table.test.js @@ -21,35 +21,8 @@ jest.mock("../showConfirmDialog", () => ({ default: jest.fn() })); -jest.mock("@mui/material/TablePagination", () => { - const React = require("react"); - return { - __esModule: true, - default: ({ count, rowsPerPage, page, onPageChange, onRowsPerPageChange }) => ( - - count:{count} - page:{page} - onPageChange({}, page + 1)} - aria-label="next-page" - > - next - - - onRowsPerPageChange({ target: { value: 20 } }) - } - aria-label="change-rows" - > - change-rows - - - ) - }; -}); - import React from "react"; -import { render, screen, within } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; import MuiTable from "../tables/mui-table"; @@ -163,37 +136,31 @@ describe("MuiTable", () => { expect(screen.getAllByTestId("action-delete")).toHaveLength(1); }); - test("renders pagination when perPage and currentPage are set", () => { + test("renders pagination (top and bottom) when perPage and currentPage are set", () => { setup(); - expect(screen.getByTestId("pagination")).toBeInTheDocument(); + expect(screen.getAllByText("mui_table.page_of")).toHaveLength(2); }); - test("pagination shows correct count", () => { - setup({ totalRows: 50 }); - expect( - within(screen.getByTestId("pagination")).getByText("count:50") - ).toBeInTheDocument(); + test("pagination reflects totalRows via the slider's page count", async () => { + setup({ totalRows: 50, perPage: 10 }); + await userEvent.click(screen.getAllByText("mui_table.page_of")[0]); + expect(screen.getAllByRole("slider")[0]).toHaveAttribute("aria-valuemax", "5"); }); test("calls onPageChange when next page button clicked", async () => { const onPageChange = jest.fn(); - setup({ onPageChange, currentPage: 1 }); + setup({ onPageChange, currentPage: 1, totalRows: 20, perPage: 10 }); await userEvent.click( - within(screen.getByTestId("pagination")).getByRole("button", { - name: "next-page" - }) + screen.getAllByRole("button", { name: "mui_table.next_page" })[0] ); expect(onPageChange).toHaveBeenCalledWith(2); }); - test("calls onPerPageChange when rows-per-page button clicked", async () => { + test("calls onPerPageChange when rows-per-page select changed", async () => { const onPerPageChange = jest.fn(); setup({ onPerPageChange }); - await userEvent.click( - within(screen.getByTestId("pagination")).getByRole("button", { - name: "change-rows" - }) - ); + await userEvent.click(screen.getAllByLabelText("mui_table.rows_per_page")[0]); + await userEvent.click(screen.getAllByRole("option", { name: "20" })[0]); expect(onPerPageChange).toHaveBeenCalledWith(20); }); diff --git a/src/components/mui/tables/components/CustomTablePagination.js b/src/components/mui/tables/components/CustomTablePagination.js index 3f490f1f..0ea1ce2c 100644 --- a/src/components/mui/tables/components/CustomTablePagination.js +++ b/src/components/mui/tables/components/CustomTablePagination.js @@ -13,63 +13,106 @@ import * as React from "react"; import T from "i18n-react/dist/i18n-react"; -import TablePagination from "@mui/material/TablePagination"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; import PropTypes from "prop-types"; import { DEFAULT_PER_PAGE, FIFTY_PER_PAGE, TWENTY_PER_PAGE } from "../../../../utils/constants"; - -const PAGINATION_SX = { - ".MuiTablePagination-toolbar": { - alignItems: "baseline", - marginTop: "1.6rem" - }, - ".MuiTablePagination-selectLabel": { - color: "rgba(0, 0, 0, 0.6)", - fontSize: "12px", - fontWeight: "normal" - }, - ".MuiTablePagination-select": { - color: "rgba(0, 0, 0, 0.6)", - fontSize: "12px", - fontWeight: "normal" - }, - ".MuiTablePagination-spacer": { - display: "none" - }, - ".MuiTablePagination-displayedRows": { - marginLeft: "auto" - } -}; +import SliderPagination from "./SliderPagination"; const BASE_PER_PAGE_OPTIONS = [DEFAULT_PER_PAGE, TWENTY_PER_PAGE, FIFTY_PER_PAGE]; -const CustomTablePagination = ({ totalRows, perPage, currentPage, onPageChange, onPerPageChange }) => { - const perPageOptions = React.useMemo(() => { - if (!onPerPageChange) return [perPage]; - return BASE_PER_PAGE_OPTIONS.includes(perPage) - ? BASE_PER_PAGE_OPTIONS - : [...BASE_PER_PAGE_OPTIONS, perPage].sort((a, b) => a - b); - }, [perPage, onPerPageChange]); - - const handlePageChange = (_, newPage) => { - onPageChange(newPage + 1); - }; +const CustomTablePagination = ({ + totalRows, + perPage, + currentPage, + onPageChange, + onPerPageChange, + showRange, + pageSliderVisible +}) => { + const perPageOptions = React.useMemo( + () => + BASE_PER_PAGE_OPTIONS.includes(perPage) + ? BASE_PER_PAGE_OPTIONS + : [...BASE_PER_PAGE_OPTIONS, perPage].sort((a, b) => a - b), + [perPage] + ); const handleRowsPerPageChange = (ev) => { - onPerPageChange(parseInt(ev.target.value, 10)); + onPerPageChange(Number(ev.target.value)); }; + const total = totalRows ?? 0; + const from = total > 0 ? (currentPage - 1) * perPage + 1 : 0; + const to = Math.min(currentPage * perPage, total); + return ( - + + + {showRange && ( + + {T.translate("mui_table.showing_range", { from, to, total })} + + )} + + + {onPerPageChange && ( + + + {T.translate("mui_table.rows_per_page")} + + + {perPageOptions.map((opt) => ( + + {opt} + + ))} + + + )} + + + ); }; @@ -78,7 +121,14 @@ CustomTablePagination.propTypes = { perPage: PropTypes.number.isRequired, currentPage: PropTypes.number.isRequired, onPageChange: PropTypes.func.isRequired, - onPerPageChange: PropTypes.func + onPerPageChange: PropTypes.func, + showRange: PropTypes.bool, + pageSliderVisible: PropTypes.bool +}; + +CustomTablePagination.defaultProps = { + showRange: false, + pageSliderVisible: false }; export default CustomTablePagination; diff --git a/src/components/mui/tables/components/SliderPagination.js b/src/components/mui/tables/components/SliderPagination.js new file mode 100644 index 00000000..fc80ab61 --- /dev/null +++ b/src/components/mui/tables/components/SliderPagination.js @@ -0,0 +1,154 @@ +/** + * 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 { useCallback, useRef, useState } from "react"; +import T from "i18n-react/dist/i18n-react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import Slider from "@mui/material/Slider"; +import ClickAwayListener from "@mui/material/ClickAwayListener"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import PropTypes from "prop-types"; + +const SliderPagination = ({ currentPage, totalRows, perPage, onPageChange, initialExpanded }) => { + const totalPages = Math.max(1, Math.ceil((totalRows ?? 0) / perPage)); + const [expanded, setExpanded] = useState(initialExpanded); + const [dragValue, setDragValue] = useState(currentPage); + const timeoutRef = useRef(null); + + const togglePill = () => { + if (expanded) { + setExpanded(false); + } else { + setDragValue(currentPage); + setExpanded(true); + } + }; + + const onSliderChange = useCallback((_ev, value) => setDragValue(value), []); + + const onSliderCommit = useCallback( + (_ev, value) => { + onPageChange(value); + timeoutRef.current = setTimeout(() => setExpanded(false), 150); + }, + [onPageChange] + ); + + // also sync dragValue so the label/slider don't go stale while the pill is expanded + const prev = () => { + const newPage = Math.max(1, currentPage - 1); + setDragValue(newPage); + onPageChange(newPage); + }; + const next = () => { + const newPage = Math.min(totalPages, currentPage + 1); + setDragValue(newPage); + onPageChange(newPage); + }; + + return ( + expanded && setExpanded(false)}> + + + {/* explicit px so a host page's root font-size reset can't shrink this */} + + + + {T.translate("mui_table.page_of", { + page: expanded ? dragValue : currentPage, + totalPages + })} + + theme.transitions.create("width"), + display: "flex", + alignItems: "center", + px: expanded ? 2 : 0 + }} + > + + + = totalPages} + aria-label={T.translate("mui_table.next_page")} + > + + + + + ); +}; + +SliderPagination.propTypes = { + currentPage: PropTypes.number.isRequired, + totalRows: PropTypes.number, + perPage: PropTypes.number.isRequired, + onPageChange: PropTypes.func.isRequired, + initialExpanded: PropTypes.bool +}; + +SliderPagination.defaultProps = { + totalRows: 0, + initialExpanded: false +}; + +export default SliderPagination; diff --git a/src/components/mui/tables/components/pagination-position.js b/src/components/mui/tables/components/pagination-position.js new file mode 100644 index 00000000..d32b0234 --- /dev/null +++ b/src/components/mui/tables/components/pagination-position.js @@ -0,0 +1,25 @@ +/** + * 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. + * */ + +// paginationPosition is a comma-separated string ("top", "bottom", "top,bottom", +// or "none") rather than an array, so it can be passed as a plain JSX string prop. +// Default lives here (not in each host component) so it only needs stating once. +const parsePaginationPosition = (paginationPosition = "top,bottom") => { + const positions = paginationPosition.split(",").map((p) => p.trim()); + return { + showTop: positions.includes("top"), + showBottom: positions.includes("bottom") + }; +}; + +export default parsePaginationPosition; diff --git a/src/components/mui/tables/components/table-shell.js b/src/components/mui/tables/components/table-shell.js index f8176265..ff0425ca 100644 --- a/src/components/mui/tables/components/table-shell.js +++ b/src/components/mui/tables/components/table-shell.js @@ -4,6 +4,7 @@ import Paper from "@mui/material/Paper"; import TableContainer from "@mui/material/TableContainer"; import PropTypes from "prop-types"; import CustomTablePagination from "./CustomTablePagination"; +import parsePaginationPosition from "./pagination-position"; import useScrollFade from "./use-scroll-fade"; import ScrollFadeOverlay from "./scroll-fade-overlay"; @@ -13,13 +14,30 @@ const TableShell = ({ perPage, currentPage, onPageChange, - onPerPageChange + onPerPageChange, + paginationPosition, + pageSliderVisible }) => { const { containerRef, showLeftFade, showRightFade } = useScrollFade(); + const showPagination = !!(perPage && currentPage && onPageChange); + const { showTop, showBottom } = parsePaginationPosition(paginationPosition); + + const renderPagination = (showRange) => ( + + ); return ( + {showPagination && showTop && renderPagination(false)} - {perPage && currentPage && onPageChange && ( - - )} + {showPagination && showBottom && renderPagination(true)} ); @@ -52,7 +62,9 @@ TableShell.propTypes = { perPage: PropTypes.number, currentPage: PropTypes.number, onPageChange: PropTypes.func, - onPerPageChange: PropTypes.func + onPerPageChange: PropTypes.func, + paginationPosition: PropTypes.string, + pageSliderVisible: PropTypes.bool }; export default TableShell; diff --git a/src/components/mui/tables/editable-table/index.js b/src/components/mui/tables/editable-table/index.js index afcd2f8a..3c458fad 100644 --- a/src/components/mui/tables/editable-table/index.js +++ b/src/components/mui/tables/editable-table/index.js @@ -146,6 +146,8 @@ const MuiTableEditable = ({ currentPage, onPageChange, onPerPageChange, + paginationPosition, + pageSliderVisible, onSort, options = { sortCol: "", sortDir: 1, disableProp: null }, getName = (item) => item.name, @@ -200,6 +202,8 @@ const MuiTableEditable = ({ currentPage={currentPage} onPageChange={onPageChange} onPerPageChange={onPerPageChange} + paginationPosition={paginationPosition} + pageSliderVisible={pageSliderVisible} > {/* TABLE HEADER */} @@ -373,6 +377,8 @@ MuiTableEditable.propTypes = { currentPage: PropTypes.number, onPageChange: PropTypes.func, onPerPageChange: PropTypes.func, + paginationPosition: PropTypes.string, + pageSliderVisible: PropTypes.bool, onSort: PropTypes.func, options: PropTypes.shape({ sortCol: PropTypes.string, diff --git a/src/components/mui/tables/mui-table/index.js b/src/components/mui/tables/mui-table/index.js index e33c5abc..edf547c3 100644 --- a/src/components/mui/tables/mui-table/index.js +++ b/src/components/mui/tables/mui-table/index.js @@ -53,6 +53,8 @@ const MuiTable = ({ currentPage, onPageChange, onPerPageChange, + paginationPosition, + pageSliderVisible, onSort, options: userOptions = {}, getName = (item) => item.name, @@ -151,6 +153,8 @@ const MuiTable = ({ currentPage={currentPage} onPageChange={onPageChange} onPerPageChange={onPerPageChange} + paginationPosition={paginationPosition} + pageSliderVisible={pageSliderVisible} > {/* TABLE HEADER */} @@ -343,6 +347,8 @@ MuiTable.propTypes = { currentPage: PropTypes.number, onPageChange: PropTypes.func, onPerPageChange: PropTypes.func, + paginationPosition: PropTypes.string, + pageSliderVisible: PropTypes.bool, onSort: PropTypes.func, options: PropTypes.object, getName: PropTypes.func, diff --git a/src/components/mui/tables/sortable-table-v2/mui-table-sortable-v2.js b/src/components/mui/tables/sortable-table-v2/mui-table-sortable-v2.js index 6de3d793..6c5a459a 100644 --- a/src/components/mui/tables/sortable-table-v2/mui-table-sortable-v2.js +++ b/src/components/mui/tables/sortable-table-v2/mui-table-sortable-v2.js @@ -36,6 +36,7 @@ import showConfirmDialog from "../../showConfirmDialog"; import SortableRow from "./sortable-row"; import TableCellContent from "../components/table-cell-content"; import CustomTablePagination from "../components/CustomTablePagination"; +import parsePaginationPosition from "../components/pagination-position"; import useDndKitReorder from "../../DragNDropList/hooks/useDndKitReorder"; const getRowId = (row, index, idKey) => @@ -51,6 +52,8 @@ const MuiTableSortableV2 = ({ currentPage, onPageChange, onPerPageChange, + paginationPosition, + pageSliderVisible, onSort, options = { sortCol: "", sortDir: 1 }, getName = (item) => item.name, @@ -107,9 +110,24 @@ const MuiTableSortableV2 = ({ } }; + const showPagination = !!(onPerPageChange && onPageChange); + const { showTop, showBottom } = parsePaginationPosition(paginationPosition); + const renderPagination = (showRange) => ( + + ); + return ( + {showPagination && showTop && renderPagination(false)} {/* PAGINATION */} - {onPerPageChange && onPageChange && ( - - )} + {showPagination && showBottom && renderPagination(true)} ); diff --git a/src/components/mui/tables/sortable-table/index.js b/src/components/mui/tables/sortable-table/index.js index 4f384131..2431a612 100644 --- a/src/components/mui/tables/sortable-table/index.js +++ b/src/components/mui/tables/sortable-table/index.js @@ -45,6 +45,8 @@ const MuiTableSortable = ({ currentPage, onPageChange, onPerPageChange, + paginationPosition, + pageSliderVisible, onSort, options = { sortCol: "", sortDir: 1 }, getName = (item) => item.name, @@ -96,6 +98,8 @@ const MuiTableSortable = ({ currentPage={currentPage} onPageChange={onPageChange} onPerPageChange={onPerPageChange} + paginationPosition={paginationPosition} + pageSliderVisible={pageSliderVisible} > {/* TABLE HEADER */} diff --git a/src/i18n/en.json b/src/i18n/en.json index c4b5e96f..c612af25 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -64,10 +64,14 @@ "mui_table": { "no_items": "No items found.", "no_data": "No data found.", - "rows_per_page": "Rows per page", + "rows_per_page": "Per Page", "sorted_desc": "sorted descending", "sorted_asc": "sorted ascending", "total": "Total", + "page_of": "Page {page} of {totalPages}", + "showing_range": "Showing {from}–{to} of {total}", + "previous_page": "Previous page", + "next_page": "Next page", "pay": "PAY", "payment": "Payment", "paid_via": "Paid via",