diff --git a/src/actions/__tests__/event-actions-notify.test.js b/src/actions/__tests__/event-actions-notify.test.js new file mode 100644 index 000000000..15c8ec259 --- /dev/null +++ b/src/actions/__tests__/event-actions-notify.test.js @@ -0,0 +1,92 @@ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import { putRequest } from "openstack-uicore-foundation/lib/utils/actions"; +import T from "i18n-react/dist/i18n-react"; +import * as methods from "../../utils/methods"; +import { notifySubmissionReopened } from "../event-actions"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: jest.fn((key) => key) } +})); + +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + __esModule: true, + ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), + putRequest: jest.fn() +})); + +const mockStore = configureStore([thunk]); + +describe("notifySubmissionReopened", () => { + const summitId = 5; + let store; + + beforeEach(() => { + jest.clearAllMocks(); + window.API_BASE_URL = "https://api.test"; + store = mockStore({ + currentSummitState: { currentSummit: { id: summitId } } + }); + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + }); + + const arrangeRequest = ( + result = Promise.resolve({ response: { recipients: 3 } }) + ) => { + putRequest.mockReturnValue(() => () => result); + }; + + it("PUTs the selection to the notify endpoint in the API's snake_case shape", async () => { + arrangeRequest(); + + await store.dispatch( + notifySubmissionReopened(42, { + speakerIds: [7, 12], + includeSubmitter: true + }) + ); + + expect(putRequest).toHaveBeenCalledTimes(1); + const [requestAction, , url, body] = putRequest.mock.calls[0]; + expect(requestAction).toBeNull(); + expect(url).toBe( + `https://api.test/api/v1/summits/${summitId}/presentations/42/submission-period/reopen/notify` + ); + expect(body).toEqual({ speaker_ids: [7, 12], include_submitter: true }); + }); + + it("reports the recipient count from the response, not a client tally", async () => { + arrangeRequest(Promise.resolve({ response: { recipients: 3 } })); + + await store.dispatch( + notifySubmissionReopened(42, { speakerIds: [7], includeSubmitter: false }) + ); + + expect(T.translate).toHaveBeenCalledWith( + "edit_event.notify_speakers_success", + { count: 3 } + ); + + const snackbar = store + .getActions() + .find((a) => a.type === "SET_SNACKBAR_MESSAGE"); + expect(snackbar).toBeDefined(); + expect(snackbar.payload.type).toBe("success"); + }); + + it("stops loading even when the request rejects", async () => { + arrangeRequest(Promise.reject(new Error("412"))); + + await expect( + store.dispatch( + notifySubmissionReopened(42, { + speakerIds: [7], + includeSubmitter: false + }) + ) + ).rejects.toThrow(); + + expect(store.getActions().map((a) => a.type)).toContain("STOP_LOADING"); + }); +}); diff --git a/src/actions/event-actions.js b/src/actions/event-actions.js index 338c6f333..8a6b1ede5 100644 --- a/src/actions/event-actions.js +++ b/src/actions/event-actions.js @@ -83,6 +83,7 @@ export const RECEIVE_EVENT_COMMENTS = "RECEIVE_EVENT_COMMENTS"; export const CHANGE_SEARCH_TERM = "CHANGE_SEARCH_TERM"; export const SUBMISSION_PERIOD_REOPENED = "SUBMISSION_PERIOD_REOPENED"; export const SUBMISSION_PERIOD_CLOSED = "SUBMISSION_PERIOD_CLOSED"; +export const SUBMISSION_REOPEN_NOTIFIED = "SUBMISSION_REOPEN_NOTIFIED"; export const ATTENDEES_EXPECTED_LEARNT = "attendees_expected_learnt"; export const ATTENDING_MEDIA = "attending_media"; @@ -815,6 +816,40 @@ export const closeSubmissionPeriod = }); }; +export const notifySubmissionReopened = + (eventId, { speakerIds, includeSubmitter }) => + async (dispatch, getState) => { + const { currentSummitState } = getState(); + + dispatch(startLoading()); + + const accessToken = await getAccessTokenSafely(); + const { currentSummit } = currentSummitState; + + const params = { access_token: accessToken }; + + return putRequest( + null, + createAction(SUBMISSION_REOPEN_NOTIFIED), + `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/presentations/${eventId}/submission-period/reopen/notify`, + { speaker_ids: speakerIds, include_submitter: includeSubmitter }, + snackbarErrorHandler + )(params)(dispatch) + .then((payload) => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("edit_event.notify_speakers_success", { + count: payload?.response?.recipients ?? 0 + }) + }) + ); + }) + .finally(() => { + dispatch(stopLoading()); + }); + }; + export const cloneEvent = (entity) => async (dispatch, getState) => { const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); diff --git a/src/components/forms/event-form/ReopenNotifyPanel.jsx b/src/components/forms/event-form/ReopenNotifyPanel.jsx new file mode 100644 index 000000000..e9ab5f666 --- /dev/null +++ b/src/components/forms/event-form/ReopenNotifyPanel.jsx @@ -0,0 +1,89 @@ +/** + * 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 React from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import { ROLE } from "./utils"; + +const ROLE_LABEL = { + [ROLE.SUBMITTER]: "edit_event.notify_role_submitter", + [ROLE.MODERATOR]: "edit_event.notify_role_moderator", + [ROLE.SPEAKER]: "edit_event.notify_role_speaker" +}; + +const ReopenNotifyPanel = ({ + rows, + checked, + onToggle, + canNotify, + onNotify +}) => ( +
+ +
+ {rows.map((row) => ( +
+ onToggle(row.key)} + /> + + {row.disabled && ( + +   + {T.translate("edit_event.notify_no_email")} + + )} +
+ ))} +
+ +
+); + +ReopenNotifyPanel.propTypes = { + rows: PropTypes.array.isRequired, + checked: PropTypes.array.isRequired, + onToggle: PropTypes.func.isRequired, + canNotify: PropTypes.bool.isRequired, + onNotify: PropTypes.func.isRequired +}; + +export default ReopenNotifyPanel; diff --git a/src/components/forms/event-form/__tests__/event-form-notify.test.js b/src/components/forms/event-form/__tests__/event-form-notify.test.js new file mode 100644 index 000000000..e11676541 --- /dev/null +++ b/src/components/forms/event-form/__tests__/event-form-notify.test.js @@ -0,0 +1,269 @@ +import React from "react"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import moment from "moment-timezone"; +import EventForm from "../index"; +import currentSummitMock from "../../../../__mocks__/currentSummitMock"; +import showConfirmDialog from "../../../mui/showConfirmDialog"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("../../../mui/showConfirmDialog", () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/inputs/member-input", + () => ({ + __esModule: true, + default: ({ id, onChange }) => ( +