From 779326beae6fe4e838a466c892a20f8401507ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 24 Apr 2026 20:24:52 -0300 Subject: [PATCH 1/2] fix: implement MUI table and components on speaker list page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/speaker-actions.js | 2 +- src/i18n/en.json | 3 +- .../summit-speakers-list-page.test.js | 225 +++++++++++++ .../speakers/summit-speakers-list-page.js | 298 +++++++++--------- src/reducers/speakers/speaker-list-reducer.js | 8 +- 5 files changed, 380 insertions(+), 156 deletions(-) create mode 100644 src/pages/speakers/__tests__/summit-speakers-list-page.test.js diff --git a/src/actions/speaker-actions.js b/src/actions/speaker-actions.js index fa8a8b9f9..22df33669 100644 --- a/src/actions/speaker-actions.js +++ b/src/actions/speaker-actions.js @@ -226,7 +226,7 @@ export const getSpeakers = createAction(RECEIVE_SPEAKERS), `${window.API_BASE_URL}/api/v1/speakers`, authErrorHandler, - { order, orderDir, page, term } + { order, orderDir, page, term, perPage } )(params)(dispatch).then(() => { dispatch(stopLoading()); }); diff --git a/src/i18n/en.json b/src/i18n/en.json index 3fb5ca6c3..80fb5eed8 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -699,7 +699,8 @@ "member_id": "Member Id", "registration_code": "Registration Code", "on_site_phone": "On Site Phone", - "delete_speaker_warning": "Are you sure you want to delete speaker" + "no_results": "No items found for this search criteria.", + "delete_speaker_warning": "Are you sure you want to delete speaker {name}" }, "edit_speaker": { "registration_code": "Registration Code", diff --git a/src/pages/speakers/__tests__/summit-speakers-list-page.test.js b/src/pages/speakers/__tests__/summit-speakers-list-page.test.js new file mode 100644 index 000000000..5f90cf350 --- /dev/null +++ b/src/pages/speakers/__tests__/summit-speakers-list-page.test.js @@ -0,0 +1,225 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithRedux } from "../../../utils/test-utils"; +import SummitSpeakerListPage from "../summit-speakers-list-page"; +import * as speakerActions from "../../../actions/speaker-actions"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({ + __esModule: true, + default: ({ data, onEdit, onDelete }) => ( +
+ {data.map((row) => ( +
+ {row.name} + {onEdit && ( + + )} + {onDelete && ( + + )} +
+ ))} +
+ ) +})); + +jest.mock("openstack-uicore-foundation/lib/components/mui/search-input", () => { + const React = require("react"); + return { + __esModule: true, + default: ({ onSearch, term }) => { + const [value, setValue] = React.useState(term || ""); + return ( + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onSearch(value); + }} + /> + ); + } + }; +}); + +jest.mock("../../../actions/speaker-actions", () => ({ + ...jest.requireActual("../../../actions/speaker-actions"), + getSpeakers: jest.fn(() => () => Promise.resolve()), + deleteSpeaker: jest.fn(() => () => Promise.resolve()) +})); + +// Mock Member so permissions are easy to control per test +jest.mock("../../../models/member", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + canAddSpeakers: jest.fn(() => true), + canEditSpeakers: jest.fn(() => true), + canDeleteSpeakers: jest.fn(() => true) + })) +})); + +const mockHistory = { push: jest.fn() }; + +const SAMPLE_SPEAKERS = [ + { id: 1, name: "Alice Smith", email: "alice@example.com", member_id: 10 }, + { id: 2, name: "Bob Jones", email: "bob@example.com", member_id: 20 } +]; + +const buildInitialState = (listOverrides = {}) => ({ + currentSpeakerListState: { + speakers: [], + term: "", + order: "id", + orderDir: 1, + currentPage: 1, + lastPage: 1, + perPage: 10, + totalSpeakers: 0, + ...listOverrides + }, + loggedUserState: { member: { groups: [] } } +}); + +const allPermissions = { + canAddSpeakers: jest.fn(() => true), + canEditSpeakers: jest.fn(() => true), + canDeleteSpeakers: jest.fn(() => true) +}; + +describe("SummitSpeakerListPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + const Member = require("../../../models/member").default; + Member.mockImplementation(() => ({ ...allPermissions })); + }); + + test("should call getSpeakers on mount", async () => { + renderWithRedux(, { + initialState: buildInitialState() + }); + + await waitFor(() => { + expect(speakerActions.getSpeakers).toHaveBeenCalledTimes(1); + }); + }); + + test("should show empty state message when no speakers", () => { + renderWithRedux(, { + initialState: buildInitialState() + }); + + expect(screen.getByText("speaker_list.no_results")).toBeInTheDocument(); + expect(screen.queryByTestId("mui-table")).not.toBeInTheDocument(); + }); + + test("should show table and total count when speakers are present", () => { + renderWithRedux(, { + initialState: buildInitialState({ + speakers: SAMPLE_SPEAKERS, + totalSpeakers: 2 + }) + }); + + expect(screen.getByTestId("mui-table")).toBeInTheDocument(); + expect(screen.getByText(/2\sspeaker_list\.speakers/)).toBeInTheDocument(); + expect( + screen.queryByText("speaker_list.no_results") + ).not.toBeInTheDocument(); + }); + + test("should show Add button when canAddSpeakers is true", () => { + renderWithRedux(, { + initialState: buildInitialState() + }); + + expect(screen.getByText("speaker_list.add_speaker")).toBeInTheDocument(); + }); + + test("should hide Add button when canAddSpeakers is false", () => { + const Member = require("../../../models/member").default; + Member.mockImplementation(() => ({ + canAddSpeakers: jest.fn(() => false), + canEditSpeakers: jest.fn(() => true), + canDeleteSpeakers: jest.fn(() => true) + })); + + renderWithRedux(, { + initialState: buildInitialState() + }); + + expect( + screen.queryByText("speaker_list.add_speaker") + ).not.toBeInTheDocument(); + }); + + test("should navigate to new speaker page on Add button click", async () => { + const user = userEvent.setup(); + renderWithRedux(, { + initialState: buildInitialState() + }); + + await user.click(screen.getByText("speaker_list.add_speaker")); + + expect(mockHistory.push).toHaveBeenCalledWith("/app/speakers/new"); + }); + + test("should navigate to speaker edit page on row edit", async () => { + const user = userEvent.setup(); + renderWithRedux(, { + initialState: buildInitialState({ + speakers: SAMPLE_SPEAKERS, + totalSpeakers: 2 + }) + }); + + await user.click(screen.getAllByText("edit-row")[0]); + + expect(mockHistory.push).toHaveBeenCalledWith("/app/speakers/1"); + }); + + test("should call deleteSpeaker and refreshes on row delete", async () => { + const user = userEvent.setup(); + renderWithRedux(, { + initialState: buildInitialState({ + speakers: SAMPLE_SPEAKERS, + totalSpeakers: 2 + }) + }); + + await user.click(screen.getAllByText("delete-row")[0]); + + await waitFor(() => { + expect(speakerActions.deleteSpeaker).toHaveBeenCalledWith(1); + }); + }); + + test("should call getSpeakers with search term on Enter", async () => { + const user = userEvent.setup(); + renderWithRedux(, { + initialState: buildInitialState() + }); + + await user.type(screen.getByTestId("search-input"), "alice{Enter}"); + + await waitFor(() => { + expect(speakerActions.getSpeakers).toHaveBeenCalledWith( + "alice", + 1, + 10, + "id", + 1 + ); + }); + }); +}); diff --git a/src/pages/speakers/summit-speakers-list-page.js b/src/pages/speakers/summit-speakers-list-page.js index 16eb770e7..1ff360a36 100644 --- a/src/pages/speakers/summit-speakers-list-page.js +++ b/src/pages/speakers/summit-speakers-list-page.js @@ -11,171 +11,169 @@ * limitations under the License. * */ -import React from "react"; +import React, { useEffect } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; -import Swal from "sweetalert2"; -import { Pagination } from "react-bootstrap"; -import { - FreeTextSearch, - Table -} from "openstack-uicore-foundation/lib/components"; +import { Box, Button, Grid2 } from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; +import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input"; import { getSpeakers, deleteSpeaker } from "../../actions/speaker-actions"; import Member from "../../models/member"; +import { DEFAULT_CURRENT_PAGE } from "../../utils/constants"; + +const SummitSpeakerListPage = ({ + member, + speakers, + term, + currentPage, + perPage, + order, + orderDir, + totalSpeakers, + getSpeakers, + deleteSpeaker, + history +}) => { + useEffect(() => { + getSpeakers(); + }, []); + + const memberObj = new Member(member); + + const handleEdit = (speaker) => { + history.push(`/app/speakers/${speaker.id}`); + }; + + const handleDelete = (speakerId) => { + deleteSpeaker(speakerId).then(() => + getSpeakers(term, DEFAULT_CURRENT_PAGE, perPage, order, orderDir) + ); + }; -class SummitSpeakerListPage extends React.Component { - constructor(props) { - super(props); - props.getSpeakers(); - - this.state = {}; - - this.handleEdit = this.handleEdit.bind(this); - this.handleDelete = this.handleDelete.bind(this); - this.handlePageChange = this.handlePageChange.bind(this); - this.handleSort = this.handleSort.bind(this); - this.handleSearch = this.handleSearch.bind(this); - this.handleNewSpeaker = this.handleNewSpeaker.bind(this); - } - - handleEdit(speaker_id) { - const { history } = this.props; - history.push(`/app/speakers/${speaker_id}`); - } + const handlePageChange = (page) => { + getSpeakers(term, page, perPage, order, orderDir); + }; - handleDelete(speakerId) { - const { deleteSpeaker, speakers } = this.props; - const speaker = speakers.find((s) => s.id === speakerId); - - Swal.fire({ - title: T.translate("general.are_you_sure"), - text: `${T.translate("speaker_list.delete_speaker_warning")} ${ - speaker.name - }`, - type: "warning", - showCancelButton: true, - confirmButtonColor: "#DD6B55", - confirmButtonText: T.translate("general.yes_delete") - }).then((result) => { - if (result.value) { - deleteSpeaker(speakerId); - } - }); - } + const handlePerPageChange = (newPerPage) => { + getSpeakers(term, DEFAULT_CURRENT_PAGE, newPerPage, order, orderDir); + }; - handlePageChange(page) { - const { term, order, orderDir, perPage } = this.props; - this.props.getSpeakers(term, page, perPage, order, orderDir); - } + const handleSort = (key, dir) => { + const keySort = key === "name" ? "last_name" : key; + getSpeakers(term, currentPage, perPage, keySort, dir); + }; - handleSort(index, key, dir) { - const { term, page, perPage } = this.props; - key = key === "name" ? "last_name" : key; - this.props.getSpeakers(term, page, perPage, key, dir); - } + const handleSearch = (searchTerm) => { + getSpeakers(searchTerm, DEFAULT_CURRENT_PAGE, perPage, order, orderDir); + }; - handleSearch(term) { - const { order, orderDir, page, perPage } = this.props; - this.props.getSpeakers(term, page, perPage, order, orderDir); + const handleNewSpeaker = () => { + history.push("/app/speakers/new"); + }; + + const columns = [ + { columnKey: "id", header: T.translate("general.id"), sortable: true }, + { columnKey: "name", header: T.translate("general.name"), sortable: true }, + { + columnKey: "email", + header: T.translate("general.email"), + sortable: true + }, + { columnKey: "member_id", header: T.translate("speaker_list.member_id") } + ]; + + const table_options = { + sortCol: order === "last_name" ? "name" : order, + sortDir: orderDir, + actions: {} + }; + + if (memberObj.canDeleteSpeakers()) { + table_options.actions.delete = { onClick: handleDelete }; } - handleNewSpeaker() { - const { history } = this.props; - history.push("/app/speakers/new"); + if (memberObj.canEditSpeakers()) { + table_options.actions.edit = { onClick: handleEdit }; } - render() { - const { - speakers, - lastPage, - currentPage, - term, - order, - orderDir, - totalSpeakers, - member - } = this.props; - - const columns = [ - { columnKey: "id", value: "Id", sortable: true }, - { columnKey: "name", value: T.translate("general.name"), sortable: true }, - { - columnKey: "email", - value: T.translate("general.email"), - sortable: true - }, - { columnKey: "member_id", value: T.translate("speaker_list.member_id") } - ]; - - const table_options = { - sortCol: order === "last_name" ? "name" : order, - sortDir: orderDir, - actions: {} - }; - - const memberObj = new Member(member); - - if (memberObj.canDeleteSpeakers()) { - table_options.actions.delete = { onClick: this.handleDelete }; - } - - if (memberObj.canEditSpeakers()) { - table_options.actions.edit = { onClick: this.handleEdit }; - } - - return ( -
-

- {" "} - {T.translate("speaker_list.speaker_list")} ({totalSpeakers}){" "} -

-
-
- +

{T.translate("speaker_list.speaker_list")}

+ + + + {totalSpeakers} {T.translate("speaker_list.speakers")} + + + + + -
-
- {memberObj.canAddSpeakers() && ( - - )} -
-
- - {speakers.length > 0 && ( -
- - - - )} - - ); - } -} + + {memberObj.canAddSpeakers() && ( + + )} + + + + {speakers.length > 0 && ( + + T.translate("speaker_list.delete_speaker_warning", { name }) + } + /> + )} + + {speakers.length === 0 && ( +
{T.translate("speaker_list.no_results")}
+ )} + + ); +}; const mapStateToProps = ({ currentSpeakerListState, loggedUserState }) => ({ ...currentSpeakerListState, diff --git a/src/reducers/speakers/speaker-list-reducer.js b/src/reducers/speakers/speaker-list-reducer.js index b038dc92a..d75c23716 100644 --- a/src/reducers/speakers/speaker-list-reducer.js +++ b/src/reducers/speakers/speaker-list-reducer.js @@ -19,8 +19,8 @@ import { } from "../../actions/speaker-actions"; const DEFAULT_STATE = { - speakers: {}, - term: null, + speakers: [], + term: "", order: "id", orderDir: 1, currentPage: 1, @@ -36,8 +36,8 @@ const speakerListReducer = (state = DEFAULT_STATE, action = {}) => { return state; } case REQUEST_SPEAKERS: { - const { order, orderDir, term, page } = payload; - return { ...state, order, orderDir, term, currentPage: page }; + const { order, orderDir, term, page, perPage } = payload; + return { ...state, order, orderDir, term, currentPage: page, perPage }; } case RECEIVE_SPEAKERS: { const { From 290acbbf4b967434d4159204f3c49309790e3adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 24 Apr 2026 20:34:25 -0300 Subject: [PATCH 2/2] fix: adjust permissions for edit and delete on mui table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/pages/speakers/summit-speakers-list-page.js | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/pages/speakers/summit-speakers-list-page.js b/src/pages/speakers/summit-speakers-list-page.js index 1ff360a36..b63806699 100644 --- a/src/pages/speakers/summit-speakers-list-page.js +++ b/src/pages/speakers/summit-speakers-list-page.js @@ -85,18 +85,9 @@ const SummitSpeakerListPage = ({ const table_options = { sortCol: order === "last_name" ? "name" : order, - sortDir: orderDir, - actions: {} + sortDir: orderDir }; - if (memberObj.canDeleteSpeakers()) { - table_options.actions.delete = { onClick: handleDelete }; - } - - if (memberObj.canEditSpeakers()) { - table_options.actions.edit = { onClick: handleEdit }; - } - return (

{T.translate("speaker_list.speaker_list")}

@@ -160,8 +151,8 @@ const SummitSpeakerListPage = ({ onPageChange={handlePageChange} onPerPageChange={handlePerPageChange} onSort={handleSort} - onDelete={handleDelete} - onEdit={handleEdit} + onDelete={memberObj.canDeleteSpeakers() ? handleDelete : null} + onEdit={memberObj.canEditSpeakers() ? handleEdit : null} deleteDialogBody={(name) => T.translate("speaker_list.delete_speaker_warning", { name }) }