From 2be9eb2226a5f0f9c3f9f1cc706f6b3d15ba63be Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 8 Sep 2026 12:54:29 -0300 Subject: [PATCH 01/17] feat: add page select to custom pagination --- .../components/CustomTablePagination.js | 144 +++++++++++++++++- .../mui/tables/components/table-shell.js | 7 +- src/components/mui/tables/mui-table/index.js | 3 + src/i18n/en.json | 5 + 4 files changed, 155 insertions(+), 4 deletions(-) diff --git a/src/components/mui/tables/components/CustomTablePagination.js b/src/components/mui/tables/components/CustomTablePagination.js index 3f490f1f..3020209b 100644 --- a/src/components/mui/tables/components/CustomTablePagination.js +++ b/src/components/mui/tables/components/CustomTablePagination.js @@ -12,8 +12,18 @@ * */ import * as React from "react"; +import { useCallback, useState } from "react"; import T from "i18n-react/dist/i18n-react"; import TablePagination from "@mui/material/TablePagination"; +import IconButton from "@mui/material/IconButton"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; +import Box from "@mui/material/Box"; +import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft"; +import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight"; +import EditIcon from "@mui/icons-material/Edit"; +import CheckIcon from "@mui/icons-material/Check"; +import CloseIcon from "@mui/icons-material/Close"; import PropTypes from "prop-types"; import { DEFAULT_PER_PAGE, FIFTY_PER_PAGE, TWENTY_PER_PAGE } from "../../../../utils/constants"; @@ -42,7 +52,63 @@ const PAGINATION_SX = { const BASE_PER_PAGE_OPTIONS = [DEFAULT_PER_PAGE, TWENTY_PER_PAGE, FIFTY_PER_PAGE]; -const CustomTablePagination = ({ totalRows, perPage, currentPage, onPageChange, onPerPageChange }) => { +// Custom actions cell: keeps the default prev/next arrows but adds a +// "go to page" toggle between them, wired to the parent's edit-mode state. +const PaginationActions = ({ count, page, rowsPerPage, onPageChange, onEditClick }) => { + const lastPage = Math.max(0, Math.ceil(count / rowsPerPage) - 1); + + return ( + + onPageChange(ev, page - 1)} + disabled={page === 0} + aria-label={T.translate("mui_table.previous_page")} + size="small" + > + + + + + + + + onPageChange(ev, page + 1)} + disabled={page >= lastPage} + aria-label={T.translate("mui_table.next_page")} + size="small" + > + + + + ); +}; + +PaginationActions.propTypes = { + count: PropTypes.number.isRequired, + page: PropTypes.number.isRequired, + rowsPerPage: PropTypes.number.isRequired, + onPageChange: PropTypes.func.isRequired, + onEditClick: PropTypes.func.isRequired +}; + +const CustomTablePagination = ({ + totalRows, + perPage, + currentPage, + onPageChange, + onPerPageChange, + showPageJump = false +}) => { + const [isEditingPage, setIsEditingPage] = useState(false); + const [pageInput, setPageInput] = useState(String(currentPage)); + + const totalPages = Math.max(1, Math.ceil((totalRows ?? 0) / perPage)); + const perPageOptions = React.useMemo(() => { if (!onPerPageChange) return [perPage]; return BASE_PER_PAGE_OPTIONS.includes(perPage) @@ -58,6 +124,77 @@ const CustomTablePagination = ({ totalRows, perPage, currentPage, onPageChange, onPerPageChange(parseInt(ev.target.value, 10)); }; + const startPageEdit = useCallback(() => { + setPageInput(String(currentPage)); + setIsEditingPage(true); + }, [currentPage]); + + const renderActions = useCallback( + (actionsProps) => + isEditingPage ? null : ( + + ), + [startPageEdit, isEditingPage] + ); + + const cancelPageEdit = () => setIsEditingPage(false); + + const commitPageEdit = () => { + const parsed = parseInt(pageInput, 10); + if (!Number.isNaN(parsed)) { + const clamped = Math.min(Math.max(parsed, 1), totalPages); + if (clamped !== currentPage) onPageChange(clamped); + } + setIsEditingPage(false); + }; + + const handlePageInputKeyDown = (ev) => { + if (ev.key === "Enter") commitPageEdit(); + if (ev.key === "Escape") cancelPageEdit(); + }; + + const renderDisplayedRows = ({ from, to, count }) => { + if (!isEditingPage) { + return `${from}-${to === -1 ? count : to} ${T.translate("mui_table.of")} ${count}`; + } + return ( + + setPageInput(ev.target.value.replace(/\D/g, ""))} + onKeyDown={handlePageInputKeyDown} + inputProps={{ + inputMode: "numeric", + pattern: "[0-9]*", + "aria-label": T.translate("mui_table.go_to_page"), + style: { textAlign: "center", padding: "4px 4px 0px" } + }} + sx={{ width: 56 }} + /> + + + + + + + + + + + + ); + }; + return ( ); @@ -78,7 +217,8 @@ CustomTablePagination.propTypes = { perPage: PropTypes.number.isRequired, currentPage: PropTypes.number.isRequired, onPageChange: PropTypes.func.isRequired, - onPerPageChange: PropTypes.func + onPerPageChange: PropTypes.func, + showPageJump: PropTypes.bool }; export default CustomTablePagination; diff --git a/src/components/mui/tables/components/table-shell.js b/src/components/mui/tables/components/table-shell.js index f8176265..1e105abc 100644 --- a/src/components/mui/tables/components/table-shell.js +++ b/src/components/mui/tables/components/table-shell.js @@ -13,7 +13,8 @@ const TableShell = ({ perPage, currentPage, onPageChange, - onPerPageChange + onPerPageChange, + showPageJump }) => { const { containerRef, showLeftFade, showRightFade } = useScrollFade(); @@ -39,6 +40,7 @@ const TableShell = ({ currentPage={currentPage} onPageChange={onPageChange} onPerPageChange={onPerPageChange} + showPageJump={showPageJump} /> )} @@ -52,7 +54,8 @@ TableShell.propTypes = { perPage: PropTypes.number, currentPage: PropTypes.number, onPageChange: PropTypes.func, - onPerPageChange: PropTypes.func + onPerPageChange: PropTypes.func, + showPageJump: PropTypes.bool }; export default TableShell; diff --git a/src/components/mui/tables/mui-table/index.js b/src/components/mui/tables/mui-table/index.js index e33c5abc..29754663 100644 --- a/src/components/mui/tables/mui-table/index.js +++ b/src/components/mui/tables/mui-table/index.js @@ -53,6 +53,7 @@ const MuiTable = ({ currentPage, onPageChange, onPerPageChange, + showPageJump, onSort, options: userOptions = {}, getName = (item) => item.name, @@ -151,6 +152,7 @@ const MuiTable = ({ currentPage={currentPage} onPageChange={onPageChange} onPerPageChange={onPerPageChange} + showPageJump={showPageJump} > {/* TABLE HEADER */} @@ -343,6 +345,7 @@ MuiTable.propTypes = { currentPage: PropTypes.number, onPageChange: PropTypes.func, onPerPageChange: PropTypes.func, + showPageJump: PropTypes.bool, onSort: PropTypes.func, options: PropTypes.object, getName: PropTypes.func, diff --git a/src/i18n/en.json b/src/i18n/en.json index c4b5e96f..afb8a9bd 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -14,6 +14,7 @@ "drop_files": "Drop images or click to select files to upload.", "search": "Search", "save": "Save", + "confirm": "Confirm", "done": "Done!", "ok": "ok", "unarchive": "Unarchive", @@ -68,6 +69,10 @@ "sorted_desc": "sorted descending", "sorted_asc": "sorted ascending", "total": "Total", + "of": "of", + "previous_page": "Previous page", + "next_page": "Next page", + "go_to_page": "Go to page", "pay": "PAY", "payment": "Payment", "paid_via": "Paid via", From 824d9ccd83fbb5d2f7c51d9dc2246087daa06567 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 8 Sep 2026 12:57:33 -0300 Subject: [PATCH 02/17] chore: add tests --- .../mui-table-custom-pagination.test.js | 79 +++++++++++++++++++ .../components/CustomTablePagination.js | 2 + 2 files changed, 81 insertions(+) create mode 100644 src/components/mui/__tests__/mui-table-custom-pagination.test.js 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..3847d9d0 --- /dev/null +++ b/src/components/mui/__tests__/mui-table-custom-pagination.test.js @@ -0,0 +1,79 @@ +/** + * 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 } 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(), + showPageJump: true, + ...overrides + }; + render(); + return props; +}; + +describe("CustomTablePagination showPageJump", () => { + test("does not render the go-to-page toggle when showPageJump is not set", () => { + setup({ showPageJump: false }); + expect( + screen.queryByRole("button", { name: "mui_table.go_to_page" }) + ).not.toBeInTheDocument(); + }); + + test("jumping to a page pre-fills the current page and calls onPageChange with the new page", async () => { + const { onPageChange } = setup(); + + await userEvent.click(screen.getByRole("button", { name: "mui_table.go_to_page" })); + + const input = screen.getByRole("textbox", { name: "mui_table.go_to_page" }); + expect(input).toHaveValue("3"); + expect( + screen.queryByRole("button", { name: "mui_table.previous_page" }) + ).not.toBeInTheDocument(); + + await userEvent.clear(input); + await userEvent.type(input, "7"); + await userEvent.click(screen.getByRole("button", { name: "general.confirm" })); + + expect(onPageChange).toHaveBeenCalledWith(7); + expect( + screen.getByRole("button", { name: "mui_table.previous_page" }) + ).toBeInTheDocument(); + }); + + test("cancelling the input restores the arrows without calling onPageChange", async () => { + const { onPageChange } = setup(); + + await userEvent.click(screen.getByRole("button", { name: "mui_table.go_to_page" })); + await userEvent.click(screen.getByRole("button", { name: "general.cancel" })); + + expect(onPageChange).not.toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: "mui_table.go_to_page" }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/mui/tables/components/CustomTablePagination.js b/src/components/mui/tables/components/CustomTablePagination.js index 3020209b..bbbc1aeb 100644 --- a/src/components/mui/tables/components/CustomTablePagination.js +++ b/src/components/mui/tables/components/CustomTablePagination.js @@ -206,6 +206,8 @@ const CustomTablePagination = ({ onRowsPerPageChange={onPerPageChange ? handleRowsPerPageChange : undefined} labelRowsPerPage={T.translate("mui_table.rows_per_page")} labelDisplayedRows={showPageJump ? renderDisplayedRows : undefined} + // default displayedRows slot is a

, invalid around the TextField's block markup + slots={showPageJump ? { displayedRows: "span" } : undefined} ActionsComponent={showPageJump ? renderActions : undefined} sx={PAGINATION_SX} /> From 62f51b82954b60fc549fc26e77f870c8565855fa Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 8 Sep 2026 12:58:29 -0300 Subject: [PATCH 03/17] v5.0.61-beta.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5d05a494..5f23fd15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.60", + "version": "5.0.61-beta.0", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From 58f3e6d9b77a5263b939fd9ad56955616dd3ebee Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 8 Sep 2026 15:56:13 -0300 Subject: [PATCH 04/17] chore: fix styles --- src/components/mui/tables/components/CustomTablePagination.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/mui/tables/components/CustomTablePagination.js b/src/components/mui/tables/components/CustomTablePagination.js index bbbc1aeb..41d7a362 100644 --- a/src/components/mui/tables/components/CustomTablePagination.js +++ b/src/components/mui/tables/components/CustomTablePagination.js @@ -206,8 +206,8 @@ const CustomTablePagination = ({ onRowsPerPageChange={onPerPageChange ? handleRowsPerPageChange : undefined} labelRowsPerPage={T.translate("mui_table.rows_per_page")} labelDisplayedRows={showPageJump ? renderDisplayedRows : undefined} - // default displayedRows slot is a

, invalid around the TextField's block markup - slots={showPageJump ? { displayedRows: "span" } : undefined} + // swap the

for a only while editing (avoids invalid nesting) — swapping it always drops the default body2 styling from the "x-y of z" text + slots={showPageJump && isEditingPage ? { displayedRows: "span" } : undefined} ActionsComponent={showPageJump ? renderActions : undefined} sx={PAGINATION_SX} /> From 65586ca569ddead5283793a73ef9525b51a8e431 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 8 Sep 2026 15:56:57 -0300 Subject: [PATCH 05/17] v5.0.61-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5f23fd15..c1de8fa7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.61-beta.0", + "version": "5.0.61-beta.1", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From 7ff3a1b81460b0568a2847004065ed6c1ea8a625 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 8 Sep 2026 16:51:57 -0300 Subject: [PATCH 06/17] chore: propagate to all pagination consumers --- src/components/mui/BulkEditTable/BulkEditTable.js | 3 +++ src/components/mui/tables/editable-table/index.js | 3 +++ .../mui/tables/sortable-table-v2/mui-table-sortable-v2.js | 2 ++ src/components/mui/tables/sortable-table/index.js | 2 ++ 4 files changed, 10 insertions(+) diff --git a/src/components/mui/BulkEditTable/BulkEditTable.js b/src/components/mui/BulkEditTable/BulkEditTable.js index 604d88fa..4967f695 100644 --- a/src/components/mui/BulkEditTable/BulkEditTable.js +++ b/src/components/mui/BulkEditTable/BulkEditTable.js @@ -42,6 +42,7 @@ const BulkEditTable = ({ currentPage, onPageChange, onPerPageChange, + showPageJump, idKey, onEdit, onDelete, @@ -199,6 +200,7 @@ const BulkEditTable = ({ currentPage={currentPage} onPageChange={onPageChange} onPerPageChange={onPerPageChange} + showPageJump={showPageJump} /> )} @@ -218,6 +220,7 @@ BulkEditTable.propTypes = { currentPage: PropTypes.number, onPageChange: PropTypes.func, onPerPageChange: PropTypes.func, + showPageJump: PropTypes.bool, onEdit: PropTypes.func, onDelete: PropTypes.func, getName: PropTypes.func, diff --git a/src/components/mui/tables/editable-table/index.js b/src/components/mui/tables/editable-table/index.js index afcd2f8a..236bca56 100644 --- a/src/components/mui/tables/editable-table/index.js +++ b/src/components/mui/tables/editable-table/index.js @@ -146,6 +146,7 @@ const MuiTableEditable = ({ currentPage, onPageChange, onPerPageChange, + showPageJump, onSort, options = { sortCol: "", sortDir: 1, disableProp: null }, getName = (item) => item.name, @@ -200,6 +201,7 @@ const MuiTableEditable = ({ currentPage={currentPage} onPageChange={onPageChange} onPerPageChange={onPerPageChange} + showPageJump={showPageJump} >

{/* TABLE HEADER */} @@ -373,6 +375,7 @@ MuiTableEditable.propTypes = { currentPage: PropTypes.number, onPageChange: PropTypes.func, onPerPageChange: PropTypes.func, + showPageJump: PropTypes.bool, onSort: PropTypes.func, options: PropTypes.shape({ sortCol: PropTypes.string, 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..1e8b2f30 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 @@ -51,6 +51,7 @@ const MuiTableSortableV2 = ({ currentPage, onPageChange, onPerPageChange, + showPageJump, onSort, options = { sortCol: "", sortDir: 1 }, getName = (item) => item.name, @@ -264,6 +265,7 @@ const MuiTableSortableV2 = ({ currentPage={currentPage} onPageChange={onPageChange} onPerPageChange={onPerPageChange} + showPageJump={showPageJump} /> )} diff --git a/src/components/mui/tables/sortable-table/index.js b/src/components/mui/tables/sortable-table/index.js index 4f384131..5967ced1 100644 --- a/src/components/mui/tables/sortable-table/index.js +++ b/src/components/mui/tables/sortable-table/index.js @@ -45,6 +45,7 @@ const MuiTableSortable = ({ currentPage, onPageChange, onPerPageChange, + showPageJump, onSort, options = { sortCol: "", sortDir: 1 }, getName = (item) => item.name, @@ -96,6 +97,7 @@ const MuiTableSortable = ({ currentPage={currentPage} onPageChange={onPageChange} onPerPageChange={onPerPageChange} + showPageJump={showPageJump} >
{/* TABLE HEADER */} From 6766210947f22f5fa92e7c7db9b848324837bff7 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Tue, 8 Sep 2026 16:52:27 -0300 Subject: [PATCH 07/17] v5.0.61-beta.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c1de8fa7..fd62e81c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.61-beta.1", + "version": "5.0.61-beta.2", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From e28b7b0082507e25c917dd435fdc905ae19ffb29 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 9 Sep 2026 10:53:53 -0300 Subject: [PATCH 08/17] v5.0.61-beta-3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fd62e81c..1adc3ad1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.61-beta.2", + "version": "5.0.61-beta-3", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From b3a3050a4a2af30fb0870a506723b55e92f96660 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 9 Sep 2026 10:58:01 -0300 Subject: [PATCH 09/17] v5.0.61-beta.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1adc3ad1..7f9880f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.61-beta-3", + "version": "5.0.61-beta.3", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From 14554c264b68c959417cc6e6c69757c894f1aaf2 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 9 Sep 2026 11:04:00 -0300 Subject: [PATCH 10/17] v5.0.61-beta.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7f9880f0..5e7c9dc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.61-beta.3", + "version": "5.0.61-beta.4", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From f7c0bc05242bd481c14c593aff2e1ee68a6e4f6b Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 9 Sep 2026 11:04:23 -0300 Subject: [PATCH 11/17] chore: change pagination label and autofocus --- .../mui/tables/components/CustomTablePagination.js | 9 +++++---- src/i18n/en.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/mui/tables/components/CustomTablePagination.js b/src/components/mui/tables/components/CustomTablePagination.js index 41d7a362..a6674553 100644 --- a/src/components/mui/tables/components/CustomTablePagination.js +++ b/src/components/mui/tables/components/CustomTablePagination.js @@ -153,9 +153,9 @@ const CustomTablePagination = ({ if (ev.key === "Escape") cancelPageEdit(); }; - const renderDisplayedRows = ({ from, to, count }) => { + const renderDisplayedRows = () => { if (!isEditingPage) { - return `${from}-${to === -1 ? count : to} ${T.translate("mui_table.of")} ${count}`; + return T.translate("mui_table.page_of", { page: currentPage, totalPages }); } return ( @@ -165,6 +165,7 @@ const CustomTablePagination = ({ value={pageInput} onChange={(ev) => setPageInput(ev.target.value.replace(/\D/g, ""))} onKeyDown={handlePageInputKeyDown} + onFocus={(ev) => ev.target.select()} inputProps={{ inputMode: "numeric", pattern: "[0-9]*", @@ -205,8 +206,8 @@ const CustomTablePagination = ({ onPageChange={handlePageChange} onRowsPerPageChange={onPerPageChange ? handleRowsPerPageChange : undefined} labelRowsPerPage={T.translate("mui_table.rows_per_page")} - labelDisplayedRows={showPageJump ? renderDisplayedRows : undefined} - // swap the

for a only while editing (avoids invalid nesting) — swapping it always drops the default body2 styling from the "x-y of z" text + labelDisplayedRows={renderDisplayedRows} + // swap the

for a only while editing (avoids invalid nesting) — swapping it always drops the default body2 styling from the "Page N of M" text slots={showPageJump && isEditingPage ? { displayedRows: "span" } : undefined} ActionsComponent={showPageJump ? renderActions : undefined} sx={PAGINATION_SX} diff --git a/src/i18n/en.json b/src/i18n/en.json index afb8a9bd..4bb64289 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -69,7 +69,7 @@ "sorted_desc": "sorted descending", "sorted_asc": "sorted ascending", "total": "Total", - "of": "of", + "page_of": "Page {page} of {totalPages}", "previous_page": "Previous page", "next_page": "Next page", "go_to_page": "Go to page", From 609aa135ab507ae21569889dccd3023bb8fcb6c0 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 9 Sep 2026 16:54:40 -0300 Subject: [PATCH 12/17] feat: change approach, new slider pagination --- .../mui/BulkEditTable/BulkEditTable.js | 33 ++- .../mui-table-custom-pagination.test.js | 70 +++-- .../mui/__tests__/mui-table-editable.test.js | 83 +----- .../__tests__/mui-table-sortable-v2.test.js | 45 +-- .../mui/__tests__/mui-table-sortable.test.js | 43 +-- .../mui/__tests__/mui-table.test.js | 57 +--- .../components/CustomTablePagination.js | 267 ++++++------------ .../mui/tables/components/SliderPagination.js | 154 ++++++++++ .../tables/components/pagination-position.js | 25 ++ .../mui/tables/components/table-shell.js | 33 ++- .../mui/tables/editable-table/index.js | 9 +- src/components/mui/tables/mui-table/index.js | 9 +- .../mui-table-sortable-v2.js | 30 +- .../mui/tables/sortable-table/index.js | 6 +- src/i18n/en.json | 5 +- 15 files changed, 426 insertions(+), 443 deletions(-) create mode 100644 src/components/mui/tables/components/SliderPagination.js create mode 100644 src/components/mui/tables/components/pagination-position.js diff --git a/src/components/mui/BulkEditTable/BulkEditTable.js b/src/components/mui/BulkEditTable/BulkEditTable.js index 4967f695..82ebab5a 100644 --- a/src/components/mui/BulkEditTable/BulkEditTable.js +++ b/src/components/mui/BulkEditTable/BulkEditTable.js @@ -29,6 +29,7 @@ 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"; const BulkEditTable = ({ @@ -42,7 +43,8 @@ const BulkEditTable = ({ currentPage, onPageChange, onPerPageChange, - showPageJump, + paginationPosition, + pageSliderVisible, idKey, onEdit, onDelete, @@ -109,6 +111,20 @@ const BulkEditTable = ({ } }; + 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)} ); @@ -220,7 +228,8 @@ BulkEditTable.propTypes = { currentPage: PropTypes.number, onPageChange: PropTypes.func, onPerPageChange: PropTypes.func, - showPageJump: PropTypes.bool, + paginationPosition: PropTypes.string, + pageSliderVisible: PropTypes.bool, onEdit: PropTypes.func, onDelete: PropTypes.func, getName: PropTypes.func, diff --git a/src/components/mui/__tests__/mui-table-custom-pagination.test.js b/src/components/mui/__tests__/mui-table-custom-pagination.test.js index 3847d9d0..59dccf72 100644 --- a/src/components/mui/__tests__/mui-table-custom-pagination.test.js +++ b/src/components/mui/__tests__/mui-table-custom-pagination.test.js @@ -17,7 +17,7 @@ jest.mock("i18n-react/dist/i18n-react", () => ({ })); import React from "react"; -import { render, screen } from "@testing-library/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"; @@ -29,51 +29,59 @@ const setup = (overrides = {}) => { currentPage: 3, onPageChange: jest.fn(), onPerPageChange: jest.fn(), - showPageJump: true, ...overrides }; render(); return props; }; -describe("CustomTablePagination showPageJump", () => { - test("does not render the go-to-page toggle when showPageJump is not set", () => { - setup({ showPageJump: false }); - expect( - screen.queryByRole("button", { name: "mui_table.go_to_page" }) - ).not.toBeInTheDocument(); +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("jumping to a page pre-fills the current page and calls onPageChange with the new page", async () => { - const { onPageChange } = setup(); + 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 }); - await userEvent.click(screen.getByRole("button", { name: "mui_table.go_to_page" })); + expect(screen.getByRole("button", { name: "mui_table.previous_page" })).toBeDisabled(); - const input = screen.getByRole("textbox", { name: "mui_table.go_to_page" }); - expect(input).toHaveValue("3"); - expect( - screen.queryByRole("button", { name: "mui_table.previous_page" }) - ).not.toBeInTheDocument(); + 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(); + }); - await userEvent.clear(input); - await userEvent.type(input, "7"); - await userEvent.click(screen.getByRole("button", { name: "general.confirm" })); + 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")); - expect(onPageChange).toHaveBeenCalledWith(7); - expect( - screen.getByRole("button", { name: "mui_table.previous_page" }) - ).toBeInTheDocument(); + const slider = screen.getByRole("slider"); + expect(slider).toHaveAttribute("aria-valuemin", "1"); + expect(slider).toHaveAttribute("aria-valuemax", "10"); + expect(slider).toHaveAttribute("aria-valuenow", "3"); }); - test("cancelling the input restores the arrows without calling onPageChange", async () => { - const { onPageChange } = setup(); + 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")); - await userEvent.click(screen.getByRole("button", { name: "mui_table.go_to_page" })); - await userEvent.click(screen.getByRole("button", { name: "general.cancel" })); + const slider = screen.getByRole("slider"); + fireEvent.keyDown(slider, { key: "ArrowRight" }); - expect(onPageChange).not.toHaveBeenCalled(); - expect( - screen.getByRole("button", { name: "mui_table.go_to_page" }) - ).toBeInTheDocument(); + 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(",")} -
- - -
- ); - } - }; -}); - // TableSortLabel shim -> renders an actual - - - ) - }; -}); - 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} - - -
- ) - }; -}); - 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} - - -
- ) - }; -}); - 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 a6674553..0ea1ce2c 100644 --- a/src/components/mui/tables/components/CustomTablePagination.js +++ b/src/components/mui/tables/components/CustomTablePagination.js @@ -12,206 +12,107 @@ * */ import * as React from "react"; -import { useCallback, useState } from "react"; import T from "i18n-react/dist/i18n-react"; -import TablePagination from "@mui/material/TablePagination"; -import IconButton from "@mui/material/IconButton"; -import TextField from "@mui/material/TextField"; -import Tooltip from "@mui/material/Tooltip"; import Box from "@mui/material/Box"; -import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft"; -import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight"; -import EditIcon from "@mui/icons-material/Edit"; -import CheckIcon from "@mui/icons-material/Check"; -import CloseIcon from "@mui/icons-material/Close"; +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]; -// Custom actions cell: keeps the default prev/next arrows but adds a -// "go to page" toggle between them, wired to the parent's edit-mode state. -const PaginationActions = ({ count, page, rowsPerPage, onPageChange, onEditClick }) => { - const lastPage = Math.max(0, Math.ceil(count / rowsPerPage) - 1); - - return ( - - onPageChange(ev, page - 1)} - disabled={page === 0} - aria-label={T.translate("mui_table.previous_page")} - size="small" - > - - - - - - - - onPageChange(ev, page + 1)} - disabled={page >= lastPage} - aria-label={T.translate("mui_table.next_page")} - size="small" - > - - - - ); -}; - -PaginationActions.propTypes = { - count: PropTypes.number.isRequired, - page: PropTypes.number.isRequired, - rowsPerPage: PropTypes.number.isRequired, - onPageChange: PropTypes.func.isRequired, - onEditClick: PropTypes.func.isRequired -}; - const CustomTablePagination = ({ totalRows, perPage, currentPage, onPageChange, onPerPageChange, - showPageJump = false + showRange, + pageSliderVisible }) => { - const [isEditingPage, setIsEditingPage] = useState(false); - const [pageInput, setPageInput] = useState(String(currentPage)); - - const totalPages = Math.max(1, Math.ceil((totalRows ?? 0) / perPage)); - - 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 handleRowsPerPageChange = (ev) => { - onPerPageChange(parseInt(ev.target.value, 10)); - }; - - const startPageEdit = useCallback(() => { - setPageInput(String(currentPage)); - setIsEditingPage(true); - }, [currentPage]); - - const renderActions = useCallback( - (actionsProps) => - isEditingPage ? null : ( - - ), - [startPageEdit, isEditingPage] + 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 cancelPageEdit = () => setIsEditingPage(false); - - const commitPageEdit = () => { - const parsed = parseInt(pageInput, 10); - if (!Number.isNaN(parsed)) { - const clamped = Math.min(Math.max(parsed, 1), totalPages); - if (clamped !== currentPage) onPageChange(clamped); - } - setIsEditingPage(false); + const handleRowsPerPageChange = (ev) => { + onPerPageChange(Number(ev.target.value)); }; - const handlePageInputKeyDown = (ev) => { - if (ev.key === "Enter") commitPageEdit(); - if (ev.key === "Escape") cancelPageEdit(); - }; + const total = totalRows ?? 0; + const from = total > 0 ? (currentPage - 1) * perPage + 1 : 0; + const to = Math.min(currentPage * perPage, total); - const renderDisplayedRows = () => { - if (!isEditingPage) { - return T.translate("mui_table.page_of", { page: currentPage, totalPages }); - } - return ( - - setPageInput(ev.target.value.replace(/\D/g, ""))} - onKeyDown={handlePageInputKeyDown} - onFocus={(ev) => ev.target.select()} - inputProps={{ - inputMode: "numeric", - pattern: "[0-9]*", - "aria-label": T.translate("mui_table.go_to_page"), - style: { textAlign: "center", padding: "4px 4px 0px" } - }} - sx={{ width: 56 }} + return ( + + + {showRange && ( + + {T.translate("mui_table.showing_range", { from, to, total })} + + )} + + + {onPerPageChange && ( + + + {T.translate("mui_table.rows_per_page")} + + + + )} + - - - - - - - - - - - ); - }; - - return ( - for a only while editing (avoids invalid nesting) — swapping it always drops the default body2 styling from the "Page N of M" text - slots={showPageJump && isEditingPage ? { displayedRows: "span" } : undefined} - ActionsComponent={showPageJump ? renderActions : undefined} - sx={PAGINATION_SX} - /> + ); }; @@ -221,7 +122,13 @@ CustomTablePagination.propTypes = { currentPage: PropTypes.number.isRequired, onPageChange: PropTypes.func.isRequired, onPerPageChange: PropTypes.func, - showPageJump: PropTypes.bool + 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 1e105abc..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"; @@ -14,13 +15,29 @@ const TableShell = ({ currentPage, onPageChange, onPerPageChange, - showPageJump + 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)} ); @@ -55,7 +63,8 @@ TableShell.propTypes = { currentPage: PropTypes.number, onPageChange: PropTypes.func, onPerPageChange: PropTypes.func, - showPageJump: PropTypes.bool + 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 236bca56..3c458fad 100644 --- a/src/components/mui/tables/editable-table/index.js +++ b/src/components/mui/tables/editable-table/index.js @@ -146,7 +146,8 @@ const MuiTableEditable = ({ currentPage, onPageChange, onPerPageChange, - showPageJump, + paginationPosition, + pageSliderVisible, onSort, options = { sortCol: "", sortDir: 1, disableProp: null }, getName = (item) => item.name, @@ -201,7 +202,8 @@ const MuiTableEditable = ({ currentPage={currentPage} onPageChange={onPageChange} onPerPageChange={onPerPageChange} - showPageJump={showPageJump} + paginationPosition={paginationPosition} + pageSliderVisible={pageSliderVisible} > {/* TABLE HEADER */} @@ -375,7 +377,8 @@ MuiTableEditable.propTypes = { currentPage: PropTypes.number, onPageChange: PropTypes.func, onPerPageChange: PropTypes.func, - showPageJump: PropTypes.bool, + 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 29754663..edf547c3 100644 --- a/src/components/mui/tables/mui-table/index.js +++ b/src/components/mui/tables/mui-table/index.js @@ -53,7 +53,8 @@ const MuiTable = ({ currentPage, onPageChange, onPerPageChange, - showPageJump, + paginationPosition, + pageSliderVisible, onSort, options: userOptions = {}, getName = (item) => item.name, @@ -152,7 +153,8 @@ const MuiTable = ({ currentPage={currentPage} onPageChange={onPageChange} onPerPageChange={onPerPageChange} - showPageJump={showPageJump} + paginationPosition={paginationPosition} + pageSliderVisible={pageSliderVisible} >
{/* TABLE HEADER */} @@ -345,7 +347,8 @@ MuiTable.propTypes = { currentPage: PropTypes.number, onPageChange: PropTypes.func, onPerPageChange: PropTypes.func, - showPageJump: PropTypes.bool, + 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 1e8b2f30..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,7 +52,8 @@ const MuiTableSortableV2 = ({ currentPage, onPageChange, onPerPageChange, - showPageJump, + paginationPosition, + pageSliderVisible, onSort, options = { sortCol: "", sortDir: 1 }, getName = (item) => item.name, @@ -108,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 5967ced1..2431a612 100644 --- a/src/components/mui/tables/sortable-table/index.js +++ b/src/components/mui/tables/sortable-table/index.js @@ -45,7 +45,8 @@ const MuiTableSortable = ({ currentPage, onPageChange, onPerPageChange, - showPageJump, + paginationPosition, + pageSliderVisible, onSort, options = { sortCol: "", sortDir: 1 }, getName = (item) => item.name, @@ -97,7 +98,8 @@ const MuiTableSortable = ({ currentPage={currentPage} onPageChange={onPageChange} onPerPageChange={onPerPageChange} - showPageJump={showPageJump} + paginationPosition={paginationPosition} + pageSliderVisible={pageSliderVisible} >
{/* TABLE HEADER */} diff --git a/src/i18n/en.json b/src/i18n/en.json index 4bb64289..c612af25 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -14,7 +14,6 @@ "drop_files": "Drop images or click to select files to upload.", "search": "Search", "save": "Save", - "confirm": "Confirm", "done": "Done!", "ok": "ok", "unarchive": "Unarchive", @@ -65,14 +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", - "go_to_page": "Go to page", "pay": "PAY", "payment": "Payment", "paid_via": "Paid via", From 63e1b50e5898357e6d7bc9bb19af51f49dc8c831 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 9 Sep 2026 16:58:18 -0300 Subject: [PATCH 13/17] v5.0.61-beta.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e7c9dc1..d4bfbb8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.61-beta.4", + "version": "5.0.61-beta.5", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From 83d1d00632d30fded4f49dd68eaa5cd598f3936f Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 9 Sep 2026 17:49:24 -0300 Subject: [PATCH 14/17] chore: adjust styles on mobile --- .../mui/BulkEditTable/BulkEditTable.js | 29 +++++++++++++----- .../__tests__/BulkEditTable.test.js | 4 +-- .../mui/BulkEditTable/components/Toolbar.js | 30 +++++++++++++++---- 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/components/mui/BulkEditTable/BulkEditTable.js b/src/components/mui/BulkEditTable/BulkEditTable.js index 82ebab5a..fbe37fd4 100644 --- a/src/components/mui/BulkEditTable/BulkEditTable.js +++ b/src/components/mui/BulkEditTable/BulkEditTable.js @@ -127,15 +127,28 @@ const BulkEditTable = ({ return ( - 0} - onEdit={enterEditMode} - onApply={handleUpdateEvents} - onCancel={cancel} - /> + + + {showPagination && showTop && ( + {renderPagination(false)} + )} + - {showPagination && showTop && renderPagination(false)} { 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/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 ? ( <> - - ) : ( - )} @@ -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; From e3b97bd6f5127ad0d0e9d7d695385a47161abad2 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Wed, 9 Sep 2026 17:50:30 -0300 Subject: [PATCH 15/17] v5.0.61-beta.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d4bfbb8b..ed69f631 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.61-beta.5", + "version": "5.0.61-beta.6", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": { From 65285ac2d735461ea2902e0c46c874b777c4c283 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Thu, 10 Sep 2026 16:34:51 -0300 Subject: [PATCH 16/17] chore: make BulkEditTable responsive as MuiTable --- .../mui/BulkEditTable/BulkEditTable.js | 14 ++++- .../BulkEditTable/BulkEditTable.module.less | 6 -- .../mui/BulkEditTable/components/Heading.js | 7 ++- .../mui/BulkEditTable/components/Row.js | 59 ++++++++++++++----- 4 files changed, 58 insertions(+), 28 deletions(-) diff --git a/src/components/mui/BulkEditTable/BulkEditTable.js b/src/components/mui/BulkEditTable/BulkEditTable.js index fbe37fd4..925c6d4b 100644 --- a/src/components/mui/BulkEditTable/BulkEditTable.js +++ b/src/components/mui/BulkEditTable/BulkEditTable.js @@ -31,6 +31,10 @@ 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, @@ -67,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 @@ -154,7 +161,7 @@ const BulkEditTable = ({ className={styles.tableWrapper} sx={{ borderRadius: 0, boxShadow: "none" }} > -
+
{columns.map((col, i) => { const sortable = !!col.sortable; - const colWidth = col.width ?? ""; return ( {col.header ?? col.label ?? col.value} @@ -218,6 +224,8 @@ const BulkEditTable = ({ columns={columns} onEdit={onEdit} onDelete={onDelete ? handleDelete : null} + collapseActions={collapseActions} + actionsBreakpoint={actionsBreakpoint} /> ))} 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/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; From 9568b1907b5847164c0462762560220dc949d9d9 Mon Sep 17 00:00:00 2001 From: Santiago Palenque Date: Thu, 10 Sep 2026 16:39:14 -0300 Subject: [PATCH 17/17] v5.0.61-beta.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ed69f631..e028b205 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openstack-uicore-foundation", - "version": "5.0.61-beta.6", + "version": "5.0.61-beta.7", "description": "ui reactjs components for openstack marketing site", "main": "lib/openstack-uicore-foundation.js", "scripts": {