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..b63806699 100644
--- a/src/pages/speakers/summit-speakers-list-page.js
+++ b/src/pages/speakers/summit-speakers-list-page.js
@@ -11,171 +11,160 @@
* 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)
+ );
+ };
+
+ const handlePageChange = (page) => {
+ getSpeakers(term, page, perPage, order, orderDir);
+ };
+
+ const handlePerPageChange = (newPerPage) => {
+ getSpeakers(term, DEFAULT_CURRENT_PAGE, newPerPage, order, orderDir);
+ };
+
+ const handleSort = (key, dir) => {
+ const keySort = key === "name" ? "last_name" : key;
+ getSpeakers(term, currentPage, perPage, keySort, dir);
+ };
-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}`);
- }
-
- 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);
- }
- });
- }
-
- handlePageChange(page) {
- const { term, order, orderDir, perPage } = this.props;
- this.props.getSpeakers(term, page, perPage, order, orderDir);
- }
-
- handleSort(index, key, dir) {
- const { term, page, perPage } = this.props;
- key = key === "name" ? "last_name" : key;
- this.props.getSpeakers(term, page, perPage, key, dir);
- }
-
- handleSearch(term) {
- const { order, orderDir, page, perPage } = this.props;
- this.props.getSpeakers(term, page, perPage, order, orderDir);
- }
-
- handleNewSpeaker() {
- const { history } = this.props;
+ const handleSearch = (searchTerm) => {
+ getSpeakers(searchTerm, DEFAULT_CURRENT_PAGE, perPage, order, orderDir);
+ };
+
+ const handleNewSpeaker = () => {
history.push("/app/speakers/new");
- }
-
- 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() && (
+ }
+ sx={{
+ height: "36px",
+ padding: "6px 16px",
+ fontSize: "1.4rem",
+ lineHeight: "2.4rem",
+ letterSpacing: "0.4px"
+ }}
+ >
+ {T.translate("speaker_list.add_speaker")}
+
+ )}
+
+
+
+ {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 {