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() && (
-
-
-
-
- )}
+ )}
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
+ })
+ }
+ />
+ >
)}