From 0bbb7216d4d75fcb3e4d26470415b8df0288fc42 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 09:11:55 -0500 Subject: [PATCH 01/17] feat: add reopen and close submission-period thunks Co-Authored-By: Claude --- src/actions/__tests__/event-actions.test.js | 148 +++++++++++++++++++- src/actions/event-actions.js | 62 +++++++- src/reducers/events/summit-event-reducer.js | 33 ++++- 3 files changed, 235 insertions(+), 8 deletions(-) diff --git a/src/actions/__tests__/event-actions.test.js b/src/actions/__tests__/event-actions.test.js index 49dbe91d9..2dea08d4c 100644 --- a/src/actions/__tests__/event-actions.test.js +++ b/src/actions/__tests__/event-actions.test.js @@ -3,17 +3,30 @@ import thunk from "redux-thunk"; import flushPromises from "flush-promises"; import { getRequest, + putRequest, + deleteRequest, postFile, getRawCSV, - downloadFileByContent + downloadFileByContent, + snackbarErrorHandler } from "openstack-uicore-foundation/lib/utils/actions"; -import { getEvents, exportEvents, importEventsCSV } from "../event-actions"; +import { + getEvent, + getEvents, + exportEvents, + importEventsCSV, + reopenSubmissionPeriod, + closeSubmissionPeriod, + SUBMISSION_PERIOD_REOPENED +} from "../event-actions"; import * as methods from "../../utils/methods"; jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ __esModule: true, ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), getRequest: jest.fn(), + putRequest: jest.fn(), + deleteRequest: jest.fn(), postFile: jest.fn(), getRawCSV: jest.fn(), downloadFileByContent: jest.fn() @@ -24,12 +37,71 @@ describe("Event Actions", () => { const mockStore = configureStore(middlewares); let capturedParams = null; + // getEvent's thunk fires a second, unrelated getRequest call (QA users + // lookup) as a side effect after its own request resolves. That call + // reuses this same mocked getRequest and would clobber capturedParams, + // so every call's params are also kept here in call order. + let capturedParamsHistory = []; beforeEach(() => { jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); getRequest.mockClear(); + putRequest.mockClear(); + deleteRequest.mockClear(); + capturedParamsHistory = []; getRequest.mockImplementation( + (requestActionCreator, receiveActionCreator) => + (params = {}) => + (dispatch) => { + capturedParams = params; + capturedParamsHistory.push(params); + + if ( + requestActionCreator && + typeof requestActionCreator === "function" + ) { + dispatch(requestActionCreator({})); + } + + return new Promise((resolve) => { + if (typeof receiveActionCreator === "function") { + dispatch(receiveActionCreator({ response: {} })); + } else { + dispatch(receiveActionCreator); + } + + resolve({ response: {} }); + }); + } + ); + + putRequest.mockImplementation( + (requestActionCreator, receiveActionCreator) => + (params = {}) => + (dispatch) => { + capturedParams = params; + + if ( + requestActionCreator && + typeof requestActionCreator === "function" + ) { + dispatch(requestActionCreator({})); + } + + return new Promise((resolve) => { + if (typeof receiveActionCreator === "function") { + dispatch(receiveActionCreator({ response: {} })); + } else { + dispatch(receiveActionCreator); + } + + resolve({ response: {} }); + }); + } + ); + + deleteRequest.mockImplementation( (requestActionCreator, receiveActionCreator) => (params = {}) => (dispatch) => { @@ -251,4 +323,76 @@ describe("Event Actions", () => { expect(actions.some((a) => a.type === "STOP_LOADING")).toBe(true); }); }); + + describe("reopenSubmissionPeriod", () => { + it("PUTs hours to the reopen endpoint for the current summit", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 7 } } + }); + + await store.dispatch(reopenSubmissionPeriod(42, 48)); + + expect(putRequest).toHaveBeenCalled(); + const [, , url, payload] = putRequest.mock.calls[0]; + expect(url).toBe( + `${window.API_BASE_URL}/api/v1/summits/7/presentations/42/submission-period/reopen` + ); + expect(payload).toEqual({ hours: 48 }); + expect(capturedParams.access_token).toBe("TOKEN"); + expect(capturedParams.expand).toBe("submission_reopened_by"); + }); + + it("dispatches SUBMISSION_PERIOD_REOPENED, not EVENT_UPDATED", async () => { + // Guards the trap-5 regression: EVENT_UPDATED replaces the entity wholesale, + // and this narrowly-expanded response would null out type_id/selection_plan_id. + const store = mockStore({ + currentSummitState: { currentSummit: { id: 7 } } + }); + + await store.dispatch(reopenSubmissionPeriod(42, 48)); + + expect(store.getActions().map((a) => a.type)).toContain( + SUBMISSION_PERIOD_REOPENED + ); + }); + + it("routes errors to snackbarErrorHandler so the API 412 text reaches the admin", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 7 } } + }); + + await store.dispatch(reopenSubmissionPeriod(42, 999)); + + const [, , , , errorHandler] = putRequest.mock.calls[0]; + expect(errorHandler).toBe(snackbarErrorHandler); + }); + }); + + describe("closeSubmissionPeriod", () => { + it("DELETEs the reopen endpoint for the current summit", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 7 } } + }); + + await store.dispatch(closeSubmissionPeriod(42)); + + expect(deleteRequest).toHaveBeenCalled(); + const [, , url] = deleteRequest.mock.calls[0]; + expect(url).toBe( + `${window.API_BASE_URL}/api/v1/summits/7/presentations/42/submission-period/reopen` + ); + }); + }); + + it("asks getEvent to expand submission_reopened_by", async () => { + const store = mockStore({ + currentSummitState: { currentSummit: { id: 7 } } + }); + + await store.dispatch(getEvent(42)); + + // getEvent's own request is always the first getRequest call; a second, + // unrelated call (QA users lookup) fires afterward as a side effect. + expect(capturedParamsHistory[0].expand).toContain("submission_reopened_by"); + }); }); diff --git a/src/actions/event-actions.js b/src/actions/event-actions.js index 556493686..5fbe2e338 100644 --- a/src/actions/event-actions.js +++ b/src/actions/event-actions.js @@ -24,6 +24,7 @@ import { showMessage, showSuccessMessage, authErrorHandler, + snackbarErrorHandler, getCSV, getRawCSV, downloadFileByContent, @@ -79,6 +80,8 @@ export const FLAG_CHANGED = "FLAG_CHANGED"; export const REQUEST_EVENT_COMMENTS = "REQUEST_EVENT_COMMENTS"; 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 ATTENDEES_EXPECTED_LEARNT = "attendees_expected_learnt"; export const ATTENDING_MEDIA = "attending_media"; @@ -452,7 +455,7 @@ export const getEvent = (eventId) => async (dispatch, getState) => { const params = { access_token: accessToken, expand: - "creator,speakers,moderator,sponsors,groups,type,type.allowed_media_upload_types,type.allowed_media_upload_types.type, slides, links, videos, media_uploads, tags, media_uploads.media_upload_type, media_uploads.media_upload_type.type,extra_questions,selection_plan,selection_plan.extra_questions, selection_plan.extra_questions.values,selection_plan.track_chair_rating_types,selection_plan.track_chair_rating_types.score_types,created_by,track_chair_scores_avg.ranking_type,actions,allowed_ticket_types,allowed_badge_features_types", + "creator,speakers,moderator,sponsors,groups,type,type.allowed_media_upload_types,type.allowed_media_upload_types.type, slides, links, videos, media_uploads, tags, media_uploads.media_upload_type, media_uploads.media_upload_type.type,extra_questions,selection_plan,selection_plan.extra_questions, selection_plan.extra_questions.values,selection_plan.track_chair_rating_types,selection_plan.track_chair_rating_types.score_types,created_by,track_chair_scores_avg.ranking_type,actions,allowed_ticket_types,allowed_badge_features_types,submission_reopened_by", fields: "allowed_ticket_types.id,allowed_ticket_types.name" }; @@ -553,7 +556,7 @@ export const saveEvent = (entity, publish) => async (dispatch, getState) => { const params = { access_token: accessToken, expand: - "creator,speakers,moderator,sponsors,groups,type,type.allowed_media_upload_types,type.allowed_media_upload_types.type, slides, links, videos, media_uploads, tags, media_uploads.media_upload_type, media_uploads.media_upload_type.type,extra_questions,selection_plan,selection_plan.track_chair_rating_types,selection_plan.track_chair_rating_types.score_types,selection_plan.extra_questions,selection_plan.extra_questions.values,created_by,track_chair_scores_avg.ranking_type,actions,allowed_ticket_types", + "creator,speakers,moderator,sponsors,groups,type,type.allowed_media_upload_types,type.allowed_media_upload_types.type, slides, links, videos, media_uploads, tags, media_uploads.media_upload_type, media_uploads.media_upload_type.type,extra_questions,selection_plan,selection_plan.track_chair_rating_types,selection_plan.track_chair_rating_types.score_types,selection_plan.extra_questions,selection_plan.extra_questions.values,created_by,track_chair_scores_avg.ranking_type,actions,allowed_ticket_types,submission_reopened_by", fields: "allowed_ticket_types.id,allowed_ticket_types.name" }; @@ -644,7 +647,7 @@ export const saveEventAsDraft = (entity) => async (dispatch, getState) => { const params = { access_token: accessToken, expand: - "creator,speakers,moderator,sponsors,groups,type,type.allowed_media_upload_types,type.allowed_media_upload_types.type, slides, links, videos, media_uploads, tags, media_uploads.media_upload_type, media_uploads.media_upload_type.type,extra_questions,selection_plan,selection_plan.track_chair_rating_types,selection_plan.track_chair_rating_types.score_types,selection_plan.extra_questions,selection_plan.extra_questions.values,created_by,track_chair_scores_avg.ranking_type,actions,allowed_ticket_types", + "creator,speakers,moderator,sponsors,groups,type,type.allowed_media_upload_types,type.allowed_media_upload_types.type, slides, links, videos, media_uploads, tags, media_uploads.media_upload_type, media_uploads.media_upload_type.type,extra_questions,selection_plan,selection_plan.track_chair_rating_types,selection_plan.track_chair_rating_types.score_types,selection_plan.extra_questions,selection_plan.extra_questions.values,created_by,track_chair_scores_avg.ranking_type,actions,allowed_ticket_types,submission_reopened_by", fields: "allowed_ticket_types.id,allowed_ticket_types.name" }; @@ -683,7 +686,7 @@ export const saveEventFieldWithoutRefresh = const params = { access_token: accessToken, expand: - "creator,speakers,moderator,sponsors,groups,type,type.allowed_media_upload_types,type.allowed_media_upload_types.type, slides, links, videos, media_uploads, tags, media_uploads.media_upload_type, media_uploads.media_upload_type.type,extra_questions,selection_plan,selection_plan.track_chair_rating_types,selection_plan.track_chair_rating_types.score_types,selection_plan.extra_questions,selection_plan.extra_questions.values,created_by,track_chair_scores_avg.ranking_type,actions,allowed_ticket_types", + "creator,speakers,moderator,sponsors,groups,type,type.allowed_media_upload_types,type.allowed_media_upload_types.type, slides, links, videos, media_uploads, tags, media_uploads.media_upload_type, media_uploads.media_upload_type.type,extra_questions,selection_plan,selection_plan.track_chair_rating_types,selection_plan.track_chair_rating_types.score_types,selection_plan.extra_questions,selection_plan.extra_questions.values,created_by,track_chair_scores_avg.ranking_type,actions,allowed_ticket_types,submission_reopened_by", fields: "allowed_ticket_types.id,allowed_ticket_types.name" }; @@ -732,6 +735,57 @@ export const upgradeEvent = (entity) => async (dispatch, getState) => { }); }; +export const reopenSubmissionPeriod = + (eventId, hours) => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const accessToken = await getAccessTokenSafely(); + const { currentSummit } = currentSummitState; + + dispatch(startLoading()); + + const params = { + access_token: accessToken, + expand: "submission_reopened_by" + }; + + return putRequest( + null, + createAction(SUBMISSION_PERIOD_REOPENED), + `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/presentations/${eventId}/submission-period/reopen`, + { hours }, + snackbarErrorHandler + )(params)(dispatch).then(() => { + dispatch(stopLoading()); + dispatch( + showSuccessMessage(T.translate("edit_event.reopen_submission_success")) + ); + }); + }; + +export const closeSubmissionPeriod = + (eventId) => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const accessToken = await getAccessTokenSafely(); + const { currentSummit } = currentSummitState; + + dispatch(startLoading()); + + const params = { access_token: accessToken }; + + return deleteRequest( + null, + createAction(SUBMISSION_PERIOD_CLOSED)({ eventId }), + `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/presentations/${eventId}/submission-period/reopen`, + null, + snackbarErrorHandler + )(params)(dispatch).then(() => { + dispatch(stopLoading()); + dispatch( + showSuccessMessage(T.translate("edit_event.close_submission_success")) + ); + }); + }; + export const cloneEvent = (entity) => async (dispatch, getState) => { const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); diff --git a/src/reducers/events/summit-event-reducer.js b/src/reducers/events/summit-event-reducer.js index 447ae7a74..7c0921d98 100644 --- a/src/reducers/events/summit-event-reducer.js +++ b/src/reducers/events/summit-event-reducer.js @@ -28,7 +28,9 @@ import { RECEIVE_EVENT_FEEDBACK, REQUEST_EVENT_COMMENTS, REQUEST_EVENT_FEEDBACK, - RESET_EVENT_FORM + RESET_EVENT_FORM, + SUBMISSION_PERIOD_REOPENED, + SUBMISSION_PERIOD_CLOSED } from "../../actions/event-actions"; import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; import { UNPUBLISHED_EVENT } from "../../actions/summit-builder-actions"; @@ -78,7 +80,10 @@ export const DEFAULT_ENTITY = { actions: [], allowed_ticket_types: [], submission_source: "Admin", - rsvp_type: "None" + rsvp_type: "None", + submission_reopened_until: "", + submission_reopened_by_id: 0, + submission_reopened_by: null }; const DEFAULT_STATE_FEEDBACK_STATE = { @@ -186,6 +191,30 @@ const summitEventReducer = (state = DEFAULT_STATE, action) => { const entity = normalizeEventResponse(payload.response); return { ...state, entity, errors: {} }; } + case SUBMISSION_PERIOD_REOPENED: { + const { response } = payload; + return { + ...state, + entity: { + ...state.entity, + submission_reopened_until: response.submission_reopened_until ?? "", + submission_reopened_by_id: response.submission_reopened_by_id ?? 0, + submission_reopened_by: response.submission_reopened_by ?? null + }, + errors: {} + }; + } + case SUBMISSION_PERIOD_CLOSED: { + return { + ...state, + entity: { + ...state.entity, + submission_reopened_until: "", + submission_reopened_by_id: 0, + submission_reopened_by: null + } + }; + } case EVENT_MATERIAL_DELETED: { const { eventMaterialId } = payload; const materials = state.entity.materials.filter( From 1b822043fb81c16016a50b351e25a275ac76129f Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 09:26:36 -0500 Subject: [PATCH 02/17] feat: offer a reopen submission control on the activity form Co-Authored-By: Claude --- .../forms/__tests__/event-form.test.js | 78 ++++++++++- src/components/forms/event-form.js | 127 +++++++++++++++++- src/i18n/en.json | 21 ++- src/pages/events/edit-summit-event-page.js | 10 +- src/utils/constants.js | 4 + 5 files changed, 234 insertions(+), 6 deletions(-) diff --git a/src/components/forms/__tests__/event-form.test.js b/src/components/forms/__tests__/event-form.test.js index 2b595530c..f22d34b7f 100644 --- a/src/components/forms/__tests__/event-form.test.js +++ b/src/components/forms/__tests__/event-form.test.js @@ -1,14 +1,20 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import EventForm from "../event-form"; 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() +})); + describe("EventForm", () => { const marketplaceHoursType = currentSummitMock.event_types.find( (t) => t.id === 935 @@ -65,6 +71,76 @@ describe("EventForm", () => { onClone: jest.fn() }; + const renderEventForm = (overrides = {}) => + render(); + + // Built on baseProps.entity (not the bare object from the task brief) because + // several unrelated, pre-existing render paths (TagInput, isEventType, + // isQuestionAllowed) dereference entity.tags / typeOpts / selectionPlansOpts + // lookups without a null guard, and baseProps.selectionPlansOpts is always []. + // type_id 930 is "Presentation" in currentSummitMock; selection_plan_id is + // falsy since selectionPlansOpts is []. class_name is required for + // isPresentation() to be true. None of this is asserted by the tests below. + const baseEntity = { + ...baseProps.entity, + id: 42, + title: "A TALK", + class_name: "Presentation", + type_id: 930, + track_id: 1, + selection_plan_id: 0, + submission_reopened_until: "", + submission_reopened_by_id: 0, + submission_reopened_by: null + }; + + it("offers the reopen control on a presentation with no active grant", () => { + renderEventForm({ entity: baseEntity }); + + expect( + screen.getByRole("button", { name: "edit_event.reopen_submission" }) + ).toBeInTheDocument(); + }); + + it("does not offer the reopen control on a new presentation", () => { + renderEventForm({ entity: { ...baseEntity, id: 0 } }); + + expect( + screen.queryByRole("button", { name: "edit_event.reopen_submission" }) + ).not.toBeInTheDocument(); + }); + + it("sends the selected preset hours to onReopenSubmission after confirmation", async () => { + const onReopenSubmission = jest.fn().mockResolvedValue({}); + showConfirmDialog.mockResolvedValue(true); + renderEventForm({ entity: baseEntity, onReopenSubmission }); + + await userEvent.selectOptions( + screen.getByLabelText("edit_event.reopen_duration"), + "48" + ); + await userEvent.click( + screen.getByRole("button", { name: "edit_event.reopen_submission" }) + ); + + await waitFor(() => + expect(onReopenSubmission).toHaveBeenCalledWith(42, 48) + ); + }); + + it("does not call onReopenSubmission when the admin cancels", async () => { + const onReopenSubmission = jest.fn(); + showConfirmDialog.mockResolvedValue(false); + renderEventForm({ entity: baseEntity, onReopenSubmission }); + + await userEvent.click( + screen.getByRole("button", { name: "edit_event.reopen_submission" }) + ); + + await waitFor(() => expect(showConfirmDialog).toHaveBeenCalled()); + expect(onReopenSubmission).not.toHaveBeenCalled(); + }); + // The calendar popup opens on the summit's start month (October 2025) for // both fields, so day 28 (Sep 28, rendered as "rdtOld") is the day right // before the summit's start date in both pickers. diff --git a/src/components/forms/event-form.js b/src/components/forms/event-form.js index c2e2ecf50..a0e0280be 100644 --- a/src/components/forms/event-form.js +++ b/src/components/forms/event-form.js @@ -15,6 +15,7 @@ import React from "react"; import T from "i18n-react/dist/i18n-react"; import "awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css"; import Swal from "sweetalert2"; +import moment from "moment-timezone"; import { Tooltip } from "react-tooltip"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"; @@ -55,11 +56,14 @@ import AuditLogs from "../audit-logs"; import { DECIMAL_DIGITS, DELTA_SECS, + DEFAULT_REOPEN_HOURS, EVENT_TYPE_FISHBOWL, EVENT_TYPE_GROUP_EVENTS, EVENT_TYPE_PRESENTATION, MILLISECONDS_TO_SECONDS, ONE_MINUTE, + REOPEN_PRESET_HOURS_48, + REOPEN_PRESET_HOURS_72, RSVP_TYPE_NONE, RSVP_TYPE_PRIVATE, RSVP_TYPE_PUBLIC @@ -67,6 +71,9 @@ import { import CopyClipboard from "../buttons/copy-clipboard"; import EventRsvpList from "../rsvp/event-rsvp-list"; import EventRsvpInvitationList from "../rsvp/event-rsvp-invitation-list"; +import showConfirmDialog from "../mui/showConfirmDialog"; + +const REOPEN_DEADLINE_FORMAT = "MMMM DD, YYYY h:mm a"; class EventForm extends React.Component { constructor(props) { @@ -78,7 +85,9 @@ class EventForm extends React.Component { showSection: "main", errors: props.errors, publish: false, - commentFilters: { ...props.commentState.filters } + commentFilters: { ...props.commentState.filters }, + reopenHours: DEFAULT_REOPEN_HOURS, + reopenCustomHours: "" }; this.formRef = React.createRef(); @@ -126,6 +135,7 @@ class EventForm extends React.Component { this.handleEventTypeChange = this.handleEventTypeChange.bind(this); this.handleRSVPTypeChange = this.handleRSVPTypeChange.bind(this); this.handleSaveIncomplete = this.handleSaveIncomplete.bind(this); + this.handleReopenSubmission = this.handleReopenSubmission.bind(this); } componentDidMount() { @@ -734,6 +744,51 @@ class EventForm extends React.Component { return entity.class_name === "Presentation"; } + isSubmissionReopened() { + const deadline = this.getReopenDeadline(); + return deadline !== null && deadline.isAfter(moment()); + } + + getReopenDeadline() { + const { entity, currentSummit } = this.props; + // normalizeEventResponse coerces server nulls to "", so "" means no grant. + if (!entity.submission_reopened_until) return null; + return epochToMomentTimeZone( + entity.submission_reopened_until, + currentSummit.time_zone_id + ); + } + + getSelectedReopenHours() { + const { reopenHours, reopenCustomHours } = this.state; + if (reopenHours !== "custom") return parseInt(reopenHours, 10); + return parseInt(reopenCustomHours, 10); + } + + async handleReopenSubmission() { + const { entity, currentSummit, onReopenSubmission } = this.props; + const hours = this.getSelectedReopenHours(); + if (!hours) return; + + // Deliberately optimistic: the deadline shown here is computed client-side for the + // confirm copy only. The server derives the real one. They agree to within the + // round trip, and naming it is what stops an admin pasting a link that will + // quietly go read-only (the CFP route hard-gates on the live grant). + const deadline = epochToMomentTimeZone( + moment().add(hours, "hours").unix(), + currentSummit.time_zone_id + ).format(REOPEN_DEADLINE_FORMAT); + + const confirmed = await showConfirmDialog({ + title: T.translate("edit_event.reopen_confirm_title"), + text: T.translate("edit_event.reopen_confirm_text", { deadline }), + iconType: "warning", + confirmButtonText: T.translate("edit_event.reopen_submission") + }); + + if (confirmed) onReopenSubmission(entity.id, hours); + } + isNew() { const { entity } = this.state; return !entity.id; @@ -911,7 +966,14 @@ class EventForm extends React.Component { } render() { - const { entity, showSection, errors, speakerToAdd } = this.state; + const { + entity, + showSection, + errors, + speakerToAdd, + reopenHours, + reopenCustomHours + } = this.state; const { currentSummit, @@ -1128,6 +1190,67 @@ class EventForm extends React.Component {

)} + {this.isPresentation() && !this.isNew() && ( +
+
+ + {!this.isSubmissionReopened() && ( +
+ + + {reopenHours === "custom" && ( + + this.setState({ reopenCustomHours: ev.target.value }) + } + /> + )} + +
+ )} +
+
+ )}
  diff --git a/src/i18n/en.json b/src/i18n/en.json index 935ec2de4..c69ecc7f1 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -712,7 +712,26 @@ "draft_state_note": "This activity was saved as a draft by the submitter and contains missing required fields. To make partial changes (e.g., re-categorize or assign tags) without filling out missing fields, use the Save as Incomplete button below. This preserves the draft status.", "save_as_incomplete": "Save as Incomplete", "save_and_mark_complete": "Save & Mark Complete", - "event_saved_as_draft": "Activity saved as draft successfully." + "event_saved_as_draft": "Activity saved as draft successfully.", + "reopen_submission": "Reopen submission", + "reopen_submission_section": "CFP submission window", + "reopen_duration": "Reopen for", + "reopen_duration_24": "24 hours", + "reopen_duration_48": "48 hours", + "reopen_duration_72": "72 hours", + "reopen_duration_custom": "Custom", + "reopen_custom_hours": "Hours", + "reopen_confirm_title": "Reopen submission for this activity?", + "reopen_confirm_text": "This lets the speaker edit this talk until {deadline}.", + "reopen_submission_success": "Submission reopened.", + "close_submission": "Close now", + "close_submission_confirm_title": "Close the submission window now?", + "close_submission_confirm_text": "The speaker will immediately lose the ability to edit this talk.", + "close_submission_success": "Submission window closed.", + "reopened_until": "Reopened until {deadline}", + "reopened_by": "by {admin}", + "reopen_deep_link_label": "Speaker link", + "reopen_deep_link_unavailable": "Speaker link unavailable: CFP_APP_BASE_URL is not configured." }, "edit_event_material": { "material": "Material", diff --git a/src/pages/events/edit-summit-event-page.js b/src/pages/events/edit-summit-event-page.js index 912646db7..23f7a0457 100644 --- a/src/pages/events/edit-summit-event-page.js +++ b/src/pages/events/edit-summit-event-page.js @@ -31,7 +31,9 @@ import { fetchExtraQuestions, fetchExtraQuestionsAnswers, cloneEvent, - upgradeEvent + upgradeEvent, + reopenSubmissionPeriod, + closeSubmissionPeriod } from "../../actions/event-actions"; import { unPublishEvent } from "../../actions/summit-builder-actions"; import { deleteEventMaterial } from "../../actions/event-material-actions"; @@ -311,6 +313,8 @@ function EditSummitEventPage(props) { getEventFeedbackCSV={getEventFeedbackCSV} onFlagChange={changeFlag} onClone={cloneEvent} + onReopenSubmission={reopenSubmissionPeriod} + onCloseSubmission={closeSubmissionPeriod} /> )}
@@ -356,5 +360,7 @@ export default connect(mapStateToProps, { getEventComments, deleteEventComment, cloneEvent, - upgradeEvent + upgradeEvent, + reopenSubmissionPeriod, + closeSubmissionPeriod })(EditSummitEventPage); diff --git a/src/utils/constants.js b/src/utils/constants.js index 846f53c8e..19609da15 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -354,3 +354,7 @@ export const IMPORT_SPONSOR_USERS_STATUS = { SUCCESS: "SUCCESS", FAILURE: "FAILURE" }; + +export const DEFAULT_REOPEN_HOURS = 24; +export const REOPEN_PRESET_HOURS_48 = 48; +export const REOPEN_PRESET_HOURS_72 = 72; From 3e6fcc5f3c8d7ea684fbf4970d9f8c5513b18dec Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 09:36:07 -0500 Subject: [PATCH 03/17] feat: show the active reopen window, close action and speaker link Co-Authored-By: Claude --- .../forms/__tests__/event-form.test.js | 104 ++++++++++++++++++ src/components/forms/event-form.js | 60 ++++++++++ 2 files changed, 164 insertions(+) diff --git a/src/components/forms/__tests__/event-form.test.js b/src/components/forms/__tests__/event-form.test.js index f22d34b7f..5847a6940 100644 --- a/src/components/forms/__tests__/event-form.test.js +++ b/src/components/forms/__tests__/event-form.test.js @@ -1,6 +1,7 @@ import React from "react"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import moment from "moment-timezone"; import EventForm from "../event-form"; import currentSummitMock from "../../../__mocks__/currentSummitMock"; import showConfirmDialog from "../../mui/showConfirmDialog"; @@ -141,6 +142,109 @@ describe("EventForm", () => { expect(onReopenSubmission).not.toHaveBeenCalled(); }); + describe("with an active reopen grant", () => { + // selection_plan_id stays 0 (not the brief's literal 9): baseProps.selectionPlansOpts + // is [], and isQuestionAllowed() throws on a truthy selection_plan_id that isn't + // found there. The deep-link test asserts against this actual value, not the brief's. + const grantedEntity = { + ...baseEntity, + submission_reopened_until: moment().add(24, "hours").unix(), + // submission_reopened_by_id is deliberately absent: One2ManyExpandSerializer + // unsets it when it writes the expanded object. + submission_reopened_by: { + id: 5, + first_name: "Ada", + last_name: "Lovelace", + email: "ada@example.org" + } + }; + + // The grant exists but the payload was not expanded, or the caller lacked the + // expand. The reopened block must still render; only attribution is missing. + const grantedUnexpandedEntity = { + ...baseEntity, + submission_reopened_until: moment().add(24, "hours").unix(), + submission_reopened_by_id: 5 + }; + + beforeEach(() => { + delete window.CFP_APP_BASE_URL; + }); + + afterEach(() => { + delete window.CFP_APP_BASE_URL; + }); + + it("shows the deadline and granting admin when a grant is active", () => { + renderEventForm({ entity: grantedEntity }); + + expect(screen.getByText(/edit_event.reopened_until/)).toBeInTheDocument(); + expect(screen.getByText(/edit_event.reopened_by/)).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "edit_event.reopen_submission" }) + ).not.toBeInTheDocument(); + }); + + it("still shows the reopened state when the payload was not expanded", () => { + renderEventForm({ entity: grantedUnexpandedEntity }); + + expect(screen.getByText(/edit_event.reopened_until/)).toBeInTheDocument(); + expect( + screen.queryByText(/edit_event.reopened_by/) + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "edit_event.close_submission" }) + ).toBeInTheDocument(); + }); + + it("treats an expired grant as no grant", () => { + renderEventForm({ + entity: { + ...grantedEntity, + submission_reopened_until: moment().subtract(1, "hours").unix() + } + }); + + expect( + screen.getByRole("button", { name: "edit_event.reopen_submission" }) + ).toBeInTheDocument(); + }); + + it("calls onCloseSubmission after confirmation", async () => { + const onCloseSubmission = jest.fn().mockResolvedValue({}); + showConfirmDialog.mockResolvedValue(true); + renderEventForm({ entity: grantedEntity, onCloseSubmission }); + + await userEvent.click( + screen.getByRole("button", { name: "edit_event.close_submission" }) + ); + + await waitFor(() => expect(onCloseSubmission).toHaveBeenCalledWith(42)); + }); + + it("renders the speaker deep link when CFP_APP_BASE_URL is set", () => { + window.CFP_APP_BASE_URL = "https://cfp.example.org"; + renderEventForm({ entity: grantedEntity }); + + // slug comes from currentSummitMock; selection_plan_id and id from grantedEntity + expect( + screen.getByText( + "https://cfp.example.org/app/2025ocpglo/all-plans/0/presentations/42/summary" + ) + ).toBeInTheDocument(); + }); + + it("degrades without the deep link when CFP_APP_BASE_URL is unset", () => { + delete window.CFP_APP_BASE_URL; + renderEventForm({ entity: grantedEntity }); + + expect(screen.queryByText(/\/summary$/)).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "edit_event.close_submission" }) + ).toBeInTheDocument(); + }); + }); + // The calendar popup opens on the summit's start month (October 2025) for // both fields, so day 28 (Sep 28, rendered as "rdtOld") is the day right // before the summit's start date in both pickers. diff --git a/src/components/forms/event-form.js b/src/components/forms/event-form.js index a0e0280be..dd6c6c41a 100644 --- a/src/components/forms/event-form.js +++ b/src/components/forms/event-form.js @@ -136,6 +136,7 @@ class EventForm extends React.Component { this.handleRSVPTypeChange = this.handleRSVPTypeChange.bind(this); this.handleSaveIncomplete = this.handleSaveIncomplete.bind(this); this.handleReopenSubmission = this.handleReopenSubmission.bind(this); + this.handleCloseSubmission = this.handleCloseSubmission.bind(this); } componentDidMount() { @@ -789,6 +790,20 @@ class EventForm extends React.Component { if (confirmed) onReopenSubmission(entity.id, hours); } + async handleCloseSubmission() { + const { entity, onCloseSubmission } = this.props; + + const confirmed = await showConfirmDialog({ + title: T.translate("edit_event.close_submission_confirm_title"), + text: T.translate("edit_event.close_submission_confirm_text"), + iconType: "warning", + confirmButtonText: T.translate("edit_event.close_submission"), + confirmButtonColor: "error" + }); + + if (confirmed) onCloseSubmission(entity.id); + } + isNew() { const { entity } = this.state; return !entity.id; @@ -1248,6 +1263,51 @@ class EventForm extends React.Component {
)} + {this.isSubmissionReopened() && ( +
+ + {T.translate("edit_event.reopened_until", { + deadline: this.getReopenDeadline().format( + REOPEN_DEADLINE_FORMAT + ) + })} + + {entity.submission_reopened_by && ( + + {T.translate("edit_event.reopened_by", { + admin: `${entity.submission_reopened_by.first_name} ${entity.submission_reopened_by.last_name}` + })} + + )} + + {window.CFP_APP_BASE_URL && ( + + +   + +   + {`${window.CFP_APP_BASE_URL}/app/${currentSummit.slug}/all-plans/${entity.selection_plan_id}/presentations/${entity.id}/summary`} + + )} +
+ )} )} From 3e0c935c3b6ae70bccce670145ec9e2371c04b95 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 09:42:35 -0500 Subject: [PATCH 04/17] refactor: dedupe speaker deep-link URL into a local const Co-Authored-By: Claude --- src/components/forms/event-form.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/forms/event-form.js b/src/components/forms/event-form.js index dd6c6c41a..664cbf03d 100644 --- a/src/components/forms/event-form.js +++ b/src/components/forms/event-form.js @@ -1167,6 +1167,8 @@ class EventForm extends React.Component { ? [] : this.getMissingDraftFields(); + const speakerDeepLink = `${window.CFP_APP_BASE_URL}/app/${currentSummit.slug}/all-plans/${entity.selection_plan_id}/presentations/${entity.id}/summary`; + return (
@@ -1299,11 +1301,9 @@ class EventForm extends React.Component { {T.translate("edit_event.reopen_deep_link_label")}   - +   - {`${window.CFP_APP_BASE_URL}/app/${currentSummit.slug}/all-plans/${entity.selection_plan_id}/presentations/${entity.id}/summary`} + {speakerDeepLink} )}
From 69e57fbd9b236134f94a55000a2eca27c96944ce Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 10:09:27 -0500 Subject: [PATCH 05/17] fix: gate CFP reopen on selection plan, disable Reopen w/o valid hours Final review fix wave for the per-activity CFP reopen feature: - Gate the reopen block on entity.selection_plan_id > 0 (matches the existing > 0 convention at the ProgressFlags gate a few lines down), not just isPresentation()/!isNew(). Without a selection plan the server 412s the reopen call, so the button previously offered an action that could only fail; this also removes the only path that could produce a /all-plans/null/ deep link. - Disable the Reopen button when no valid hours value is selected (e.g. Custom left blank/non-numeric), so it doesn't silently no-op. - Make isSubmissionReopened() null-safe with optional chaining, since epochToMomentTimeZone can return undefined (not null) when time_zone_id is missing. - Drop the unreferenced edit_event.reopen_deep_link_unavailable i18n key. Updated the event-form test fixtures (selection_plan_id + a matching selectionPlansOpts entry) so existing reopen-flow tests still clear the new gate, and added focused tests for the no-selection-plan and disabled-button cases. Co-Authored-By: Claude --- .../forms/__tests__/event-form.test.js | 44 +++- src/components/forms/event-form.js | 197 +++++++++--------- src/i18n/en.json | 3 +- 3 files changed, 136 insertions(+), 108 deletions(-) diff --git a/src/components/forms/__tests__/event-form.test.js b/src/components/forms/__tests__/event-form.test.js index 5847a6940..0471d8a8a 100644 --- a/src/components/forms/__tests__/event-form.test.js +++ b/src/components/forms/__tests__/event-form.test.js @@ -28,7 +28,9 @@ describe("EventForm", () => { trackOpts: currentSummitMock.tracks, typeOpts: currentSummitMock.event_types, locationOpts: currentSummitMock.locations, - selectionPlansOpts: [], + selectionPlansOpts: [ + { id: 99, allowed_presentation_questions: [], track_groups: [] } + ], rsvpTemplateOpts: [], actionTypes: [], entity: { @@ -77,10 +79,14 @@ describe("EventForm", () => { // Built on baseProps.entity (not the bare object from the task brief) because // several unrelated, pre-existing render paths (TagInput, isEventType, - // isQuestionAllowed) dereference entity.tags / typeOpts / selectionPlansOpts - // lookups without a null guard, and baseProps.selectionPlansOpts is always []. - // type_id 930 is "Presentation" in currentSummitMock; selection_plan_id is - // falsy since selectionPlansOpts is []. class_name is required for + // isQuestionAllowed, the track-scoped selection_plans_ddl filter) dereference + // entity.tags / typeOpts / selectionPlansOpts lookups without a null guard. + // selection_plan_id (99) matches the single entry in + // baseProps.selectionPlansOpts (including its empty track_groups, so the + // track_id-based ddl filter doesn't crash) so isQuestionAllowed() doesn't + // crash either, and truthy selection_plan_id is required for the reopen + // block to render at all now that it gates on it. type_id 930 is + // "Presentation" in currentSummitMock. class_name is required for // isPresentation() to be true. None of this is asserted by the tests below. const baseEntity = { ...baseProps.entity, @@ -89,7 +95,7 @@ describe("EventForm", () => { class_name: "Presentation", type_id: 930, track_id: 1, - selection_plan_id: 0, + selection_plan_id: 99, submission_reopened_until: "", submission_reopened_by_id: 0, submission_reopened_by: null @@ -111,6 +117,27 @@ describe("EventForm", () => { ).not.toBeInTheDocument(); }); + it("does not offer the reopen control on a presentation with no selection plan", () => { + renderEventForm({ entity: { ...baseEntity, selection_plan_id: null } }); + + expect( + screen.queryByRole("button", { name: "edit_event.reopen_submission" }) + ).not.toBeInTheDocument(); + }); + + it("disables the reopen button when no valid hours value is selected", async () => { + renderEventForm({ entity: baseEntity }); + + await userEvent.selectOptions( + screen.getByLabelText("edit_event.reopen_duration"), + "custom" + ); + + expect( + screen.getByRole("button", { name: "edit_event.reopen_submission" }) + ).toBeDisabled(); + }); + it("sends the selected preset hours to onReopenSubmission after confirmation", async () => { const onReopenSubmission = jest.fn().mockResolvedValue({}); showConfirmDialog.mockResolvedValue(true); @@ -143,9 +170,6 @@ describe("EventForm", () => { }); describe("with an active reopen grant", () => { - // selection_plan_id stays 0 (not the brief's literal 9): baseProps.selectionPlansOpts - // is [], and isQuestionAllowed() throws on a truthy selection_plan_id that isn't - // found there. The deep-link test asserts against this actual value, not the brief's. const grantedEntity = { ...baseEntity, submission_reopened_until: moment().add(24, "hours").unix(), @@ -229,7 +253,7 @@ describe("EventForm", () => { // slug comes from currentSummitMock; selection_plan_id and id from grantedEntity expect( screen.getByText( - "https://cfp.example.org/app/2025ocpglo/all-plans/0/presentations/42/summary" + "https://cfp.example.org/app/2025ocpglo/all-plans/99/presentations/42/summary" ) ).toBeInTheDocument(); }); diff --git a/src/components/forms/event-form.js b/src/components/forms/event-form.js index 664cbf03d..cdb203241 100644 --- a/src/components/forms/event-form.js +++ b/src/components/forms/event-form.js @@ -747,7 +747,7 @@ class EventForm extends React.Component { isSubmissionReopened() { const deadline = this.getReopenDeadline(); - return deadline !== null && deadline.isAfter(moment()); + return !!deadline?.isAfter(moment()); } getReopenDeadline() { @@ -1207,110 +1207,115 @@ class EventForm extends React.Component {

)} - {this.isPresentation() && !this.isNew() && ( -
-
- - {!this.isSubmissionReopened() && ( -
- - - {reopenHours === "custom" && ( - + {T.translate("edit_event.reopen_duration")} + + + {reopenHours === "custom" && ( + + this.setState({ reopenCustomHours: ev.target.value }) + } + /> + )} + +
+ )} + {this.isSubmissionReopened() && ( +
- {T.translate("edit_event.reopen_submission")} - -
- )} - {this.isSubmissionReopened() && ( -
- - {T.translate("edit_event.reopened_until", { - deadline: this.getReopenDeadline().format( - REOPEN_DEADLINE_FORMAT - ) - })} - - {entity.submission_reopened_by && ( - {T.translate("edit_event.reopened_by", { - admin: `${entity.submission_reopened_by.first_name} ${entity.submission_reopened_by.last_name}` + {T.translate("edit_event.reopened_until", { + deadline: this.getReopenDeadline().format( + REOPEN_DEADLINE_FORMAT + ) })} - )} - - {window.CFP_APP_BASE_URL && ( - - -   - -   - {speakerDeepLink} - - )} -
- )} + {entity.submission_reopened_by && ( + + {T.translate("edit_event.reopened_by", { + admin: `${entity.submission_reopened_by.first_name} ${entity.submission_reopened_by.last_name}` + })} + + )} + + {window.CFP_APP_BASE_URL && ( + + +   + +   + {speakerDeepLink} + + )} +
+ )} +
- - )} + )}
  diff --git a/src/i18n/en.json b/src/i18n/en.json index c69ecc7f1..9fbd32767 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -730,8 +730,7 @@ "close_submission_success": "Submission window closed.", "reopened_until": "Reopened until {deadline}", "reopened_by": "by {admin}", - "reopen_deep_link_label": "Speaker link", - "reopen_deep_link_unavailable": "Speaker link unavailable: CFP_APP_BASE_URL is not configured." + "reopen_deep_link_label": "Speaker link" }, "edit_event_material": { "material": "Material", From 39a3cdd32a37d6c8ac51ec16c4b9371a57672544 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 11:06:54 -0500 Subject: [PATCH 06/17] fix: reject non-positive custom reopen hours parseInt let "-1" through as a truthy negative, so the Reopen button stayed enabled, the confirm dialog named a deadline in the past, and the request went out only for the server to 412 it. Guard at the getter rather than at each call site, so the existing truthiness checks in handleReopenSubmission and the button's disabled prop both become correct without changing either. Co-Authored-By: Claude --- .../forms/__tests__/event-form.test.js | 17 +++++++++++++++++ src/components/forms/event-form.js | 9 +++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/components/forms/__tests__/event-form.test.js b/src/components/forms/__tests__/event-form.test.js index 0471d8a8a..306dba980 100644 --- a/src/components/forms/__tests__/event-form.test.js +++ b/src/components/forms/__tests__/event-form.test.js @@ -138,6 +138,23 @@ describe("EventForm", () => { ).toBeDisabled(); }); + it("keeps the reopen button disabled for a negative custom hours value", async () => { + renderEventForm({ entity: baseEntity }); + + await userEvent.selectOptions( + screen.getByLabelText("edit_event.reopen_duration"), + "custom" + ); + await userEvent.type( + screen.getByPlaceholderText("edit_event.reopen_custom_hours"), + "-1" + ); + + expect( + screen.getByRole("button", { name: "edit_event.reopen_submission" }) + ).toBeDisabled(); + }); + it("sends the selected preset hours to onReopenSubmission after confirmation", async () => { const onReopenSubmission = jest.fn().mockResolvedValue({}); showConfirmDialog.mockResolvedValue(true); diff --git a/src/components/forms/event-form.js b/src/components/forms/event-form.js index cdb203241..a83f428a6 100644 --- a/src/components/forms/event-form.js +++ b/src/components/forms/event-form.js @@ -762,8 +762,13 @@ class EventForm extends React.Component { getSelectedReopenHours() { const { reopenHours, reopenCustomHours } = this.state; - if (reopenHours !== "custom") return parseInt(reopenHours, 10); - return parseInt(reopenCustomHours, 10); + const hours = parseInt( + reopenHours === "custom" ? reopenCustomHours : reopenHours, + 10 + ); + // parseInt passes "-1" through as a truthy negative, which would confirm a + // deadline in the past and then 412. Only a positive count is a valid window. + return hours > 0 ? hours : 0; } async handleReopenSubmission() { From 5de6bcec1711b19c503770343671db39dfe662ef Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 11:07:10 -0500 Subject: [PATCH 07/17] docs: record why the reopen thunks omit a terminal catch An API error rejects the returned promise and the form fire-and-forgets it, which is the same shape as saveEvent and every other write thunk in this file. Matching the repo convention was a deliberate choice, so record it where the next reader will look rather than in a PR comment. Co-Authored-By: Claude --- src/actions/event-actions.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/actions/event-actions.js b/src/actions/event-actions.js index 5fbe2e338..75efa205e 100644 --- a/src/actions/event-actions.js +++ b/src/actions/event-actions.js @@ -735,6 +735,11 @@ export const upgradeEvent = (entity) => async (dispatch, getState) => { }); }; +// Both reopen thunks return the request promise without a terminal .catch, so an +// API error rejects it and the caller fire-and-forgets. That is deliberate: it is +// the same shape as saveEvent and every other write thunk here, and the error is +// already surfaced to the admin by snackbarErrorHandler before the rejection. A +// local .catch would diverge from the repo's convention to fix nothing visible. export const reopenSubmissionPeriod = (eventId, hours) => async (dispatch, getState) => { const { currentSummitState } = getState(); From 4e0d00942bfb14f1e7226b78cfb0e4f8bcf9079e Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 12:32:32 -0500 Subject: [PATCH 08/17] fix: read the reopen thunks from props on the activity page The page destructured every other action from props but not these two, so the JSX referenced the module import instead of the dispatch-bound prop. Calling it returned an un-dispatched thunk: the Reopen and Close now buttons issued no request and raised no error. Unit tests could not catch this. EventForm is presentational and its tests inject onReopenSubmission directly, so the page wiring is never exercised; the full suite was green with the buttons inert. Found by driving the real app against the dev API. Co-Authored-By: Claude --- src/pages/events/edit-summit-event-page.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/events/edit-summit-event-page.js b/src/pages/events/edit-summit-event-page.js index 23f7a0457..716fdbdd6 100644 --- a/src/pages/events/edit-summit-event-page.js +++ b/src/pages/events/edit-summit-event-page.js @@ -243,7 +243,9 @@ function EditSummitEventPage(props) { getEventFeedbackCSV, changeFlag, cloneEvent, - upgradeEvent + upgradeEvent, + reopenSubmissionPeriod, + closeSubmissionPeriod } = props; if (loading) return null; From 355a62470f819ea7ed4726fef37650572bc92394 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 12:32:45 -0500 Subject: [PATCH 09/17] fix: swallow the expected 412 rejection on the reopen handlers The no-client-cap design makes an over-ceiling hours value an expected outcome, not a fault: the server answers 412 and snackbarErrorHandler puts its message ("hours must be between 1 and 168.") in front of the admin, which is the only way the ceiling is discoverable. uicore's response handler rejects after running that handler, so every such 412 also escaped as an unhandled rejection, reaching Sentry as a fault and raising a full-screen error overlay in development. Catch at the two call sites rather than in the thunks, keeping them the same shape as saveEvent and the other write thunks. Co-Authored-By: Claude --- src/actions/event-actions.js | 10 +++++----- src/components/forms/event-form.js | 9 +++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/actions/event-actions.js b/src/actions/event-actions.js index 75efa205e..eb9257eb9 100644 --- a/src/actions/event-actions.js +++ b/src/actions/event-actions.js @@ -735,11 +735,11 @@ export const upgradeEvent = (entity) => async (dispatch, getState) => { }); }; -// Both reopen thunks return the request promise without a terminal .catch, so an -// API error rejects it and the caller fire-and-forgets. That is deliberate: it is -// the same shape as saveEvent and every other write thunk here, and the error is -// already surfaced to the admin by snackbarErrorHandler before the rejection. A -// local .catch would diverge from the repo's convention to fix nothing visible. +// Both reopen thunks return the request promise without a terminal .catch, matching +// saveEvent and every other write thunk here. Their callers in event-form.js swallow +// the rejection instead, because the no-client-cap design makes an over-ceiling 412 an +// expected outcome rather than a fault: snackbarErrorHandler has already shown the +// API's message, so letting it also reach Sentry as an unhandled rejection is noise. export const reopenSubmissionPeriod = (eventId, hours) => async (dispatch, getState) => { const { currentSummitState } = getState(); diff --git a/src/components/forms/event-form.js b/src/components/forms/event-form.js index a83f428a6..9e259567f 100644 --- a/src/components/forms/event-form.js +++ b/src/components/forms/event-form.js @@ -792,7 +792,10 @@ class EventForm extends React.Component { confirmButtonText: T.translate("edit_event.reopen_submission") }); - if (confirmed) onReopenSubmission(entity.id, hours); + // snackbarErrorHandler has already put the API message in front of the admin, and an + // over-ceiling hours value is an expected 412 rather than a fault. Swallow the + // rejection so it doesn't reach Sentry as an unhandled one. + if (confirmed) onReopenSubmission(entity.id, hours)?.catch(() => {}); } async handleCloseSubmission() { @@ -806,7 +809,9 @@ class EventForm extends React.Component { confirmButtonColor: "error" }); - if (confirmed) onCloseSubmission(entity.id); + // See handleReopenSubmission: the error is already surfaced, so don't let the + // rejection escape as an unhandled one. + if (confirmed) onCloseSubmission(entity.id)?.catch(() => {}); } isNew() { From 5c51fd1599c83d4d6697d4fe3a9f52430d15040d Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 13:49:14 -0500 Subject: [PATCH 10/17] fix: require a positive integer for custom reopen hours parseInt read "1.5" and "1e3" as 1, so both enabled the button and would have silently granted a one hour window instead of what the admin typed. "1e3" also never reached the server's 412 for 1000 hours, which is the only way the ceiling is discoverable. Verified in Chrome rather than assumed: a number input preserves the raw "1e3" and "1.5" in .value, so this is reachable, not a jsdom artifact. The test sets the value instead of typing it because jsdom normalises a typed "1e3" to "1000", which would pass against the old code. Co-Authored-By: Claude --- .../forms/__tests__/event-form.test.js | 40 +++++++++++-------- src/components/forms/event-form.js | 14 +++---- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/components/forms/__tests__/event-form.test.js b/src/components/forms/__tests__/event-form.test.js index 306dba980..9c15970ed 100644 --- a/src/components/forms/__tests__/event-form.test.js +++ b/src/components/forms/__tests__/event-form.test.js @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen, waitFor } from "@testing-library/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 "../event-form"; @@ -138,22 +138,30 @@ describe("EventForm", () => { ).toBeDisabled(); }); - it("keeps the reopen button disabled for a negative custom hours value", async () => { - renderEventForm({ entity: baseEntity }); - - await userEvent.selectOptions( - screen.getByLabelText("edit_event.reopen_duration"), - "custom" - ); - await userEvent.type( - screen.getByPlaceholderText("edit_event.reopen_custom_hours"), - "-1" - ); + // "1.5" and "1e3" are the ones that matter: parseInt reads both as 1, so without a + // positive-integer check the admin silently gets a one hour window instead of what + // they typed, and "1e3" never reaches the server's 412 for 1000 hours. + // Set the value rather than typing it: jsdom normalises a typed "1e3" to "1000", + // while a real browser keeps "1e3" in a number input. + it.each([["-1"], ["0"], ["1.5"], ["1e3"], ["abc"]])( + "keeps the reopen button disabled for the custom hours value %s", + async (value) => { + renderEventForm({ entity: baseEntity }); + + await userEvent.selectOptions( + screen.getByLabelText("edit_event.reopen_duration"), + "custom" + ); + fireEvent.change( + screen.getByPlaceholderText("edit_event.reopen_custom_hours"), + { target: { value } } + ); - expect( - screen.getByRole("button", { name: "edit_event.reopen_submission" }) - ).toBeDisabled(); - }); + expect( + screen.getByRole("button", { name: "edit_event.reopen_submission" }) + ).toBeDisabled(); + } + ); it("sends the selected preset hours to onReopenSubmission after confirmation", async () => { const onReopenSubmission = jest.fn().mockResolvedValue({}); diff --git a/src/components/forms/event-form.js b/src/components/forms/event-form.js index 9e259567f..d482be52b 100644 --- a/src/components/forms/event-form.js +++ b/src/components/forms/event-form.js @@ -762,13 +762,13 @@ class EventForm extends React.Component { getSelectedReopenHours() { const { reopenHours, reopenCustomHours } = this.state; - const hours = parseInt( - reopenHours === "custom" ? reopenCustomHours : reopenHours, - 10 - ); - // parseInt passes "-1" through as a truthy negative, which would confirm a - // deadline in the past and then 412. Only a positive count is a valid window. - return hours > 0 ? hours : 0; + const raw = String( + reopenHours === "custom" ? reopenCustomHours : reopenHours + ).trim(); + // Not parseInt: it reads "-1" as a truthy negative, and "1.5"/"1e3" as 1, which + // would silently grant an hour instead of what the admin typed. Only a plain + // positive integer is a valid window. + return /^\d+$/.test(raw) && Number(raw) > 0 ? Number(raw) : 0; } async handleReopenSubmission() { From 213b3819c8ef24b6632256dca4c58cf21954f6af Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 13:54:55 -0500 Subject: [PATCH 11/17] test: guard the activity page against forwarding raw action imports Covers the regression fixed in 4e0d0094, where the page forwarded the module import instead of the dispatch-bound prop and the reopen controls issued no request while the suite stayed green. Asserts on store.dispatch rather than on the action creator. Once the actions module is mocked the raw import and the connect-bound prop are the same jest.fn, so asserting the creator was called passes even with the bug present; only the dispatch assertion fails. Verified by reintroducing the defect: both tests fail, and the creator assertion is not the one that catches it. Co-Authored-By: Claude --- .../__tests__/edit-summit-event-page.test.js | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/pages/events/__tests__/edit-summit-event-page.test.js diff --git a/src/pages/events/__tests__/edit-summit-event-page.test.js b/src/pages/events/__tests__/edit-summit-event-page.test.js new file mode 100644 index 000000000..8a175ff95 --- /dev/null +++ b/src/pages/events/__tests__/edit-summit-event-page.test.js @@ -0,0 +1,100 @@ +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { screen } from "@testing-library/react"; +import EditSummitEventPage from "../edit-summit-event-page"; +import { renderWithRedux } from "../../../utils/test-utils"; + +// The form is stubbed to just the two controls under test. This suite is about how the +// page forwards its actions, not about anything the form renders. +jest.mock("../../../components/forms/event-form", () => (props) => ( +
+ + +
+)); + +jest.mock("../../../actions/event-actions", () => ({ + saveEvent: jest.fn(), + saveEventAsDraft: jest.fn(), + saveEventFieldWithoutRefresh: jest.fn(), + attachFile: jest.fn(), + getEvents: jest.fn(), + removeImage: jest.fn(), + getEventFeedback: jest.fn(), + deleteEventFeedback: jest.fn(), + getEventFeedbackCSV: jest.fn(), + changeFlag: jest.fn(), + getActionTypes: jest.fn(() => ({ type: "GET_ACTION_TYPES_MOCK" })), + getEventComments: jest.fn(), + fetchExtraQuestions: jest.fn(), + fetchExtraQuestionsAnswers: jest.fn(), + cloneEvent: jest.fn(), + upgradeEvent: jest.fn(), + reopenSubmissionPeriod: jest.fn(() => ({ type: "REOPEN_SUBMISSION_MOCK" })), + closeSubmissionPeriod: jest.fn(() => ({ type: "CLOSE_SUBMISSION_MOCK" })) +})); + +const EventActions = jest.requireMock("../../../actions/event-actions"); + +describe("EditSummitEventPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const baseState = { + currentSummitState: { + currentSummit: { id: 12, selection_plans: [] }, + loading: false + }, + currentSummitEventState: { + entity: { id: 42, selection_plan_id: 99 }, + errors: {}, + levelOptions: [], + feedbackState: { term: "", page: 1, comments: [] }, + commentState: { filters: {}, comments: [] }, + actionTypes: [] + }, + currentRsvpTemplateListState: { rsvpTemplates: [] }, + currentEventListState: {}, + auditLogState: {} + }; + + const renderPage = () => + renderWithRedux(, { + initialState: baseState + }); + + // These two assert on store.dispatch, NOT on the action creator, and that is the whole + // point of the suite. Once the actions module is mocked, the raw module import and the + // connect-bound prop are the same jest.fn, so `expect(creator).toHaveBeenCalled()` + // passes even when the page forwards the un-dispatched import instead of the prop -- + // which is exactly the regression these guard (the reopen controls were inert because + // the page never destructured the two thunks off props). + it("dispatches the reopen thunk rather than forwarding the raw import", async () => { + const user = userEvent.setup(); + const { store } = renderPage(); + + await user.click(screen.getByText("reopen")); + + expect(EventActions.reopenSubmissionPeriod).toHaveBeenCalledWith(42, 24); + expect(store.dispatch).toHaveBeenCalledWith({ + type: "REOPEN_SUBMISSION_MOCK" + }); + }); + + it("dispatches the close thunk rather than forwarding the raw import", async () => { + const user = userEvent.setup(); + const { store } = renderPage(); + + await user.click(screen.getByText("close")); + + expect(EventActions.closeSubmissionPeriod).toHaveBeenCalledWith(42); + expect(store.dispatch).toHaveBeenCalledWith({ + type: "CLOSE_SUBMISSION_MOCK" + }); + }); +}); From bf6ab5ba63c0c90256864960c479e578ed2665f3 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 14:17:47 -0500 Subject: [PATCH 12/17] fix: ignore a reopen or close response for a different activity Both responses merged into the single current entity with no check of which activity they belonged to. Granting or revoking on activity A and navigating to B before the request finished left B showing A's grant, with A's deadline and attribution and a deep link for a window that was never opened on B. The close action already carried its eventId and the reducer simply never read it; the reopen action now carries one too, and both cases drop a response whose id does not match the loaded entity. The guard returns the same state object, so a discarded response cannot trigger a re-render. Reproduced in the browser by holding the request and navigating mid flight: without the guard the other activity flips to the reopened state when the response lands, with it the activity is untouched. Co-Authored-By: Claude --- src/actions/event-actions.js | 5 +- .../__tests__/summit-event-reducer.test.js | 108 ++++++++++++++++++ src/reducers/events/summit-event-reducer.js | 5 + 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/reducers/events/__tests__/summit-event-reducer.test.js diff --git a/src/actions/event-actions.js b/src/actions/event-actions.js index eb9257eb9..4b2f03487 100644 --- a/src/actions/event-actions.js +++ b/src/actions/event-actions.js @@ -755,7 +755,10 @@ export const reopenSubmissionPeriod = return putRequest( null, - createAction(SUBMISSION_PERIOD_REOPENED), + // Carry the source event id so the reducer can drop a response that lands after + // the admin has navigated to a different activity. + (payload) => + createAction(SUBMISSION_PERIOD_REOPENED)({ ...payload, eventId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/presentations/${eventId}/submission-period/reopen`, { hours }, snackbarErrorHandler diff --git a/src/reducers/events/__tests__/summit-event-reducer.test.js b/src/reducers/events/__tests__/summit-event-reducer.test.js new file mode 100644 index 000000000..c5f9c6a84 --- /dev/null +++ b/src/reducers/events/__tests__/summit-event-reducer.test.js @@ -0,0 +1,108 @@ +import summitEventReducer from "../summit-event-reducer"; +import { + SUBMISSION_PERIOD_REOPENED, + SUBMISSION_PERIOD_CLOSED +} from "../../../actions/event-actions"; + +describe("summitEventReducer submission period", () => { + const stateForEvent = (id, overrides = {}) => ({ + entity: { + id, + title: "A TALK", + selection_plan_id: 99, + submission_reopened_until: "", + submission_reopened_by_id: 0, + submission_reopened_by: null, + ...overrides + }, + errors: {} + }); + + const reopenedResponse = { + submission_reopened_until: 1786550400, + submission_reopened_by: { + id: 5, + first_name: "Ada", + last_name: "Lovelace", + email: "ada@example.org" + } + }; + + it("applies a reopen response to the activity it was issued for", () => { + const next = summitEventReducer(stateForEvent(42), { + type: SUBMISSION_PERIOD_REOPENED, + payload: { eventId: 42, response: reopenedResponse } + }); + + expect(next.entity.submission_reopened_until).toBe(1786550400); + expect(next.entity.submission_reopened_by.first_name).toBe("Ada"); + }); + + // The admin grants on activity A, navigates to B before the request finishes, B loads, + // then A's response lands. Without the eventId guard it merges A's grant onto B. + it("ignores a reopen response that arrives after the admin moved to another activity", () => { + const stateOnB = stateForEvent(43); + + const next = summitEventReducer(stateOnB, { + type: SUBMISSION_PERIOD_REOPENED, + payload: { eventId: 42, response: reopenedResponse } + }); + + expect(next).toBe(stateOnB); + expect(next.entity.submission_reopened_until).toBe(""); + expect(next.entity.submission_reopened_by).toBeNull(); + }); + + it("clears the grant on the activity the close was issued for", () => { + const granted = stateForEvent(42, { + submission_reopened_until: 1786550400, + submission_reopened_by_id: 5, + submission_reopened_by: { id: 5 } + }); + + const next = summitEventReducer(granted, { + type: SUBMISSION_PERIOD_CLOSED, + payload: { eventId: 42 } + }); + + expect(next.entity.submission_reopened_until).toBe(""); + expect(next.entity.submission_reopened_by_id).toBe(0); + expect(next.entity.submission_reopened_by).toBeNull(); + }); + + it("ignores a close response that arrives after the admin moved to another activity", () => { + const grantedOnB = stateForEvent(43, { + submission_reopened_until: 1786550400, + submission_reopened_by_id: 5, + submission_reopened_by: { id: 5 } + }); + + const next = summitEventReducer(grantedOnB, { + type: SUBMISSION_PERIOD_CLOSED, + payload: { eventId: 42 } + }); + + expect(next).toBe(grantedOnB); + expect(next.entity.submission_reopened_until).toBe(1786550400); + }); + + // The server sends null for an ungranted window, and this response does not pass + // through normalizeEventResponse, so the nulls arrive as real nulls rather than "". + it("coerces a null reopen response back to the empty-state defaults", () => { + const next = summitEventReducer(stateForEvent(42), { + type: SUBMISSION_PERIOD_REOPENED, + payload: { + eventId: 42, + response: { + submission_reopened_until: null, + submission_reopened_by_id: null, + submission_reopened_by: null + } + } + }); + + expect(next.entity.submission_reopened_until).toBe(""); + expect(next.entity.submission_reopened_by_id).toBe(0); + expect(next.entity.submission_reopened_by).toBeNull(); + }); +}); diff --git a/src/reducers/events/summit-event-reducer.js b/src/reducers/events/summit-event-reducer.js index 7c0921d98..b52a6f3b4 100644 --- a/src/reducers/events/summit-event-reducer.js +++ b/src/reducers/events/summit-event-reducer.js @@ -193,6 +193,9 @@ const summitEventReducer = (state = DEFAULT_STATE, action) => { } case SUBMISSION_PERIOD_REOPENED: { const { response } = payload; + // A response that lands after the admin navigated to another activity would + // otherwise merge this grant onto whatever event is now loaded. + if (payload.eventId !== state.entity.id) return state; return { ...state, entity: { @@ -205,6 +208,8 @@ const summitEventReducer = (state = DEFAULT_STATE, action) => { }; } case SUBMISSION_PERIOD_CLOSED: { + // Same guard as the reopen case above. + if (payload.eventId !== state.entity.id) return state; return { ...state, entity: { From 0f439b6d1d3596e02612237751c1515edc121b68 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 14:32:06 -0500 Subject: [PATCH 13/17] fix: bring the reopen thunks in line with the async-thunk conventions Convention pass against the show-admin playbooks, including the async lifecycle rules proposed in ftn-docsnsklz PR #64. - Dispatch startLoading() before the token await. A refresh can take seconds, and anything dispatched after it leaves a window where the UI is neither blocked nor marked in-flight. - Move stopLoading() into .finally(). snackbarErrorHandler happens to clear it on the error paths today, so this fixes no live defect, but .then() alone is one library change away from a stuck overlay. - Give the custom-hours input an accessible name. It had only a placeholder, which is not a name, while the select beside it already had a label. The test now selects it by label rather than placeholder. - Report success through snackbarSuccessHandler. The feature already used the MUI confirm dialog and the MUI error snackbar, so a SweetAlert success left it mixing two feedback systems. The one pre-existing showSuccessMessage call in this file pairs Swal with authErrorHandler, which is consistent in the legacy direction; this makes ours consistent in the MUI direction. Co-Authored-By: Claude --- src/actions/event-actions.js | 71 ++++++++++++------- .../forms/__tests__/event-form.test.js | 2 +- src/components/forms/event-form.js | 32 +++++---- 3 files changed, 65 insertions(+), 40 deletions(-) diff --git a/src/actions/event-actions.js b/src/actions/event-actions.js index 4b2f03487..338c6f333 100644 --- a/src/actions/event-actions.js +++ b/src/actions/event-actions.js @@ -25,6 +25,7 @@ import { showSuccessMessage, authErrorHandler, snackbarErrorHandler, + snackbarSuccessHandler, getCSV, getRawCSV, downloadFileByContent, @@ -743,41 +744,55 @@ export const upgradeEvent = (entity) => async (dispatch, getState) => { export const reopenSubmissionPeriod = (eventId, hours) => async (dispatch, getState) => { const { currentSummitState } = getState(); - const accessToken = await getAccessTokenSafely(); - const { currentSummit } = currentSummitState; + // Before the token await: a refresh can take seconds, and anything dispatched + // after it leaves a window where the UI is neither blocked nor marked in-flight. dispatch(startLoading()); + const accessToken = await getAccessTokenSafely(); + const { currentSummit } = currentSummitState; + const params = { access_token: accessToken, expand: "submission_reopened_by" }; - return putRequest( - null, - // Carry the source event id so the reducer can drop a response that lands after - // the admin has navigated to a different activity. - (payload) => - createAction(SUBMISSION_PERIOD_REOPENED)({ ...payload, eventId }), - `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/presentations/${eventId}/submission-period/reopen`, - { hours }, - snackbarErrorHandler - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - dispatch( - showSuccessMessage(T.translate("edit_event.reopen_submission_success")) - ); - }); + return ( + putRequest( + null, + // Carry the source event id so the reducer can drop a response that lands after + // the admin has navigated to a different activity. + (payload) => + createAction(SUBMISSION_PERIOD_REOPENED)({ ...payload, eventId }), + `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/presentations/${eventId}/submission-period/reopen`, + { hours }, + snackbarErrorHandler + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("edit_event.reopen_submission_success") + }) + ); + }) + // finally, not then: a failed request must still clear the overlay. + .finally(() => { + dispatch(stopLoading()); + }) + ); }; export const closeSubmissionPeriod = (eventId) => async (dispatch, getState) => { const { currentSummitState } = getState(); - const accessToken = await getAccessTokenSafely(); - const { currentSummit } = currentSummitState; + // See reopenSubmissionPeriod: in-flight flag before the token await. dispatch(startLoading()); + const accessToken = await getAccessTokenSafely(); + const { currentSummit } = currentSummitState; + const params = { access_token: accessToken }; return deleteRequest( @@ -786,12 +801,18 @@ export const closeSubmissionPeriod = `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/presentations/${eventId}/submission-period/reopen`, null, snackbarErrorHandler - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - dispatch( - showSuccessMessage(T.translate("edit_event.close_submission_success")) - ); - }); + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("edit_event.close_submission_success") + }) + ); + }) + .finally(() => { + dispatch(stopLoading()); + }); }; export const cloneEvent = (entity) => async (dispatch, getState) => { diff --git a/src/components/forms/__tests__/event-form.test.js b/src/components/forms/__tests__/event-form.test.js index 9c15970ed..b36658bb8 100644 --- a/src/components/forms/__tests__/event-form.test.js +++ b/src/components/forms/__tests__/event-form.test.js @@ -153,7 +153,7 @@ describe("EventForm", () => { "custom" ); fireEvent.change( - screen.getByPlaceholderText("edit_event.reopen_custom_hours"), + screen.getByLabelText("edit_event.reopen_custom_hours"), { target: { value } } ); diff --git a/src/components/forms/event-form.js b/src/components/forms/event-form.js index d482be52b..912005c70 100644 --- a/src/components/forms/event-form.js +++ b/src/components/forms/event-form.js @@ -1255,20 +1255,24 @@ class EventForm extends React.Component { {reopenHours === "custom" && ( - - this.setState({ reopenCustomHours: ev.target.value }) - } - /> + <> + + + this.setState({ + reopenCustomHours: ev.target.value + }) + } + /> + )}