diff --git a/.env.example b/.env.example
index 7a5aff8a1..62a5616db 100644
--- a/.env.example
+++ b/.env.example
@@ -40,6 +40,7 @@ SENTRY_PROJECT=
SENTRY_TRACE_SAMPLE_RATE=
SENTRY_TRACE_PROPAGATION_TARGETS=
CFP_APP_BASE_URL=
+CFP_MAX_REOPEN_HOURS=168
DROPBOX_MATERIALIZER_API_BASE_URL=
DROPBOX_MATERIALIZER_API_SCOPES="dropbox-materializer/read dropbox-materializer/write"
S3_MEDIA_UPLOADS_ENDPOINT_URL=https://fntech.sfo2.digitaloceanspaces.com
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..338c6f333 100644
--- a/src/actions/event-actions.js
+++ b/src/actions/event-actions.js
@@ -24,6 +24,8 @@ import {
showMessage,
showSuccessMessage,
authErrorHandler,
+ snackbarErrorHandler,
+ snackbarSuccessHandler,
getCSV,
getRawCSV,
downloadFileByContent,
@@ -79,6 +81,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 +456,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 +557,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 +648,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 +687,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 +736,85 @@ export const upgradeEvent = (entity) => async (dispatch, getState) => {
});
};
+// 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();
+
+ // 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(
+ 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();
+
+ // 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(
+ 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(
+ snackbarSuccessHandler({
+ title: T.translate("general.success"),
+ html: T.translate("edit_event.close_submission_success")
+ })
+ );
+ })
+ .finally(() => {
+ dispatch(stopLoading());
+ });
+ };
+
export const cloneEvent = (entity) => async (dispatch, getState) => {
const { currentSummitState } = getState();
const accessToken = await getAccessTokenSafely();
diff --git a/src/app.js b/src/app.js
index 55520153b..612f8745b 100644
--- a/src/app.js
+++ b/src/app.js
@@ -105,6 +105,7 @@ window.SENTRY_TRACE_SAMPLE_RATE = process.env.SENTRY_TRACE_SAMPLE_RATE;
window.SENTRY_TRACE_PROPAGATION_TARGETS =
process.env.SENTRY_TRACE_PROPAGATION_TARGETS;
window.CFP_APP_BASE_URL = process.env.CFP_APP_BASE_URL;
+window.CFP_MAX_REOPEN_HOURS = process.env.CFP_MAX_REOPEN_HOURS;
window.DROPBOX_MATERIALIZER_API_BASE_URL =
process.env.DROPBOX_MATERIALIZER_API_BASE_URL;
window.FILE_UPLOAD_ALLOWED_EXTENSIONS =
diff --git a/src/components/forms/__tests__/event-form.test.js b/src/components/forms/__tests__/event-form.test.js
index 2b595530c..81b730f59 100644
--- a/src/components/forms/__tests__/event-form.test.js
+++ b/src/components/forms/__tests__/event-form.test.js
@@ -1,14 +1,21 @@
import React from "react";
-import { render, screen } 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";
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
@@ -21,7 +28,17 @@ describe("EventForm", () => {
trackOpts: currentSummitMock.tracks,
typeOpts: currentSummitMock.event_types,
locationOpts: currentSummitMock.locations,
- selectionPlansOpts: [],
+ // is_enabled + a submission_end_date in the past are what make the reopen block
+ // applicable at all: the API only grants a reopen once the window has ended.
+ selectionPlansOpts: [
+ {
+ id: 99,
+ is_enabled: true,
+ submission_end_date: moment().subtract(7, "days").unix(),
+ allowed_presentation_questions: [],
+ track_groups: []
+ }
+ ],
rsvpTemplateOpts: [],
actionTypes: [],
entity: {
@@ -65,6 +82,369 @@ 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, 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,
+ id: 42,
+ title: "A TALK",
+ class_name: "Presentation",
+ type_id: 930,
+ track_id: 1,
+ selection_plan_id: 99,
+ 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("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();
+ });
+
+ // The API only reopens a window that has actually ended on an enabled plan. Keying
+ // the UI on the grant alone would offer a button the server can only 412, and would
+ // announce a deadline that is no longer the operative one.
+ const planWith = (over) => ({
+ ...baseProps.selectionPlansOpts[0],
+ ...over
+ });
+
+ it("does not offer the reopen control while the plan's window is still open", () => {
+ renderEventForm({
+ entity: baseEntity,
+ selectionPlansOpts: [
+ planWith({ submission_end_date: moment().add(7, "days").unix() })
+ ]
+ });
+
+ expect(
+ screen.queryByRole("button", { name: "edit_event.reopen_submission" })
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not offer the reopen control on a disabled plan", () => {
+ renderEventForm({
+ entity: baseEntity,
+ selectionPlansOpts: [planWith({ is_enabled: false })]
+ });
+
+ expect(
+ screen.queryByRole("button", { name: "edit_event.reopen_submission" })
+ ).not.toBeInTheDocument();
+ });
+
+ // The ops case smarcet raised on the call-for-presentations PR: a grant is issued,
+ // then the plan's submission_end_date is extended past it. The speaker now edits
+ // under normal open-window rules, so the grant's deadline is not what constrains
+ // them and must not be presented as if it were.
+ it("does not announce a grant once the plan window has been extended past it", () => {
+ renderEventForm({
+ entity: {
+ ...baseEntity,
+ submission_reopened_until: moment().add(24, "hours").unix()
+ },
+ selectionPlansOpts: [
+ planWith({ submission_end_date: moment().add(7, "days").unix() })
+ ]
+ });
+
+ expect(
+ screen.queryByText(/edit_event.reopened_until/)
+ ).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();
+ });
+
+ // "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.
+ // "9999999999" is ten digits and a plain integer but overflows moment, which threw in the
+ // confirm dialog before the admin saw it. Only reachable with no ceiling configured.
+ it.each([["-1"], ["0"], ["1.5"], ["1e3"], ["abc"], ["9999999999"]])(
+ "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.getByLabelText("edit_event.reopen_custom_hours"),
+ { target: { value } }
+ );
+
+ expect(
+ screen.getByRole("button", { name: "edit_event.reopen_submission" })
+ ).toBeDisabled();
+ }
+ );
+
+ // CFP_MAX_REOPEN_HOURS mirrors the server's ceiling so an over-limit value is
+ // caught before the confirm dialog instead of by the 412 after it.
+ describe("with a configured reopen ceiling", () => {
+ beforeEach(() => {
+ window.CFP_MAX_REOPEN_HOURS = "48";
+ });
+
+ afterEach(() => {
+ delete window.CFP_MAX_REOPEN_HOURS;
+ });
+
+ it("keeps the reopen button disabled for a custom value above the ceiling", async () => {
+ renderEventForm({ entity: baseEntity });
+
+ await userEvent.selectOptions(
+ screen.getByLabelText("edit_event.reopen_duration"),
+ "custom"
+ );
+ fireEvent.change(
+ screen.getByLabelText("edit_event.reopen_custom_hours_capped"),
+ { target: { value: "49" } }
+ );
+
+ expect(
+ screen.getByRole("button", { name: "edit_event.reopen_submission" })
+ ).toBeDisabled();
+ });
+
+ it("still accepts a custom value on the ceiling", async () => {
+ const onReopenSubmission = jest.fn().mockResolvedValue({});
+ showConfirmDialog.mockResolvedValue(true);
+ renderEventForm({ entity: baseEntity, onReopenSubmission });
+
+ await userEvent.selectOptions(
+ screen.getByLabelText("edit_event.reopen_duration"),
+ "custom"
+ );
+ fireEvent.change(
+ screen.getByLabelText("edit_event.reopen_custom_hours_capped"),
+ { target: { value: "48" } }
+ );
+ await userEvent.click(
+ screen.getByRole("button", { name: "edit_event.reopen_submission" })
+ );
+
+ await waitFor(() =>
+ expect(onReopenSubmission).toHaveBeenCalledWith(42, 48)
+ );
+ });
+
+ // The ceiling applies to the presets too, so a deployment that sets it below
+ // 72 can't offer a preset the server would refuse.
+ it("keeps the reopen button disabled for a preset above the ceiling", async () => {
+ renderEventForm({ entity: baseEntity });
+
+ await userEvent.selectOptions(
+ screen.getByLabelText("edit_event.reopen_duration"),
+ "72"
+ );
+
+ expect(
+ screen.getByRole("button", { name: "edit_event.reopen_submission" })
+ ).toBeDisabled();
+ });
+ });
+
+ // Unset means uncapped: the server's 412 stays the only ceiling, so a deployment
+ // that never sets CFP_MAX_REOPEN_HOURS behaves exactly as it did before.
+ it("leaves the custom hours uncapped when CFP_MAX_REOPEN_HOURS is unset", async () => {
+ const onReopenSubmission = jest.fn().mockResolvedValue({});
+ showConfirmDialog.mockResolvedValue(true);
+ renderEventForm({ entity: baseEntity, onReopenSubmission });
+
+ await userEvent.selectOptions(
+ screen.getByLabelText("edit_event.reopen_duration"),
+ "custom"
+ );
+ fireEvent.change(screen.getByLabelText("edit_event.reopen_custom_hours"), {
+ target: { value: "5000" }
+ });
+ await userEvent.click(
+ screen.getByRole("button", { name: "edit_event.reopen_submission" })
+ );
+
+ await waitFor(() =>
+ expect(onReopenSubmission).toHaveBeenCalledWith(42, 5000)
+ );
+ });
+
+ 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();
+ });
+
+ describe("with an active reopen grant", () => {
+ 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/99/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 c2e2ecf50..12c503091 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,8 @@ 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);
+ this.handleCloseSubmission = this.handleCloseSubmission.bind(this);
}
componentDidMount() {
@@ -734,6 +745,115 @@ class EventForm extends React.Component {
return entity.class_name === "Presentation";
}
+ // The API's isSubmissionReopened() requires three things: the plan enabled, its
+ // submission window actually ended, and a live grant. Keying the UI on the grant
+ // alone lets it announce a deadline the server no longer treats as operative --
+ // e.g. an admin grants a reopen, then extends the plan's submission_end_date past
+ // it, and the speaker is editing under normal open-window rules again.
+ // entity comes from state, not props, because that is what the render gate reads.
+ // handleChangeSelectionPlan writes selection_plan_id into state without saving, and
+ // componentDidUpdate only syncs the other way, so reading props here would judge
+ // eligibility against the persisted plan while the form displays a different one.
+ isReopenApplicable() {
+ const { selectionPlansOpts } = this.props;
+ const { entity } = this.state;
+ const plan = selectionPlansOpts?.find(
+ (sp) => sp.id === entity.selection_plan_id
+ );
+ if (!plan || plan.is_enabled === false || !plan.submission_end_date) {
+ return false;
+ }
+ return moment().unix() > plan.submission_end_date;
+ }
+
+ isSubmissionReopened() {
+ const deadline = this.getReopenDeadline();
+ return !!deadline?.isAfter(moment());
+ }
+
+ getReopenDeadline() {
+ const { currentSummit } = this.props;
+ const { entity } = this.state;
+ // 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
+ );
+ }
+
+ // Mirrors the server's CFP_MAX_REOPEN_HOURS so an over-ceiling value is caught
+ // before the confirm dialog rather than by the 412 after it. dotenv values are
+ // strings, hence the coercion. Unset means uncapped: the server's 412 stays the
+ // authoritative ceiling, so a deployment that never sets this behaves as before.
+ getMaxReopenHours() {
+ return Number(window.CFP_MAX_REOPEN_HOURS) || 0;
+ }
+
+ getSelectedReopenHours() {
+ const { reopenHours, reopenCustomHours } = this.state;
+ 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.
+ if (!/^\d+$/.test(raw) || Number(raw) <= 0) return 0;
+ const hours = Number(raw);
+ // Uncapped, a digit-only value can still overflow moment: the deadline comes back NaN,
+ // which epochToMomentTimeZone passes through unwrapped, so the confirm dialog throws.
+ if (!moment().add(hours, "hours").isValid()) return 0;
+ const max = this.getMaxReopenHours();
+ // Applied to the presets too, not just the custom entry, so a ceiling
+ // configured below 72 can't offer a preset the server would refuse.
+ return max && hours > max ? 0 : hours;
+ }
+
+ async handleReopenSubmission() {
+ const { currentSummit, onReopenSubmission } = this.props;
+ const { entity } = this.state;
+ 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")
+ });
+
+ // 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() {
+ const { onCloseSubmission } = this.props;
+ const { entity } = this.state;
+
+ 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"
+ });
+
+ // 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() {
const { entity } = this.state;
return !entity.id;
@@ -911,7 +1031,16 @@ class EventForm extends React.Component {
}
render() {
- const { entity, showSection, errors, speakerToAdd } = this.state;
+ const {
+ entity,
+ showSection,
+ errors,
+ speakerToAdd,
+ reopenHours,
+ reopenCustomHours
+ } = this.state;
+
+ const maxReopenHours = this.getMaxReopenHours();
const {
currentSummit,
@@ -1090,6 +1219,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 (
@@ -1128,6 +1259,126 @@ class EventForm extends React.Component {
)}
+ {this.isPresentation() &&
+ !this.isNew() &&
+ entity.selection_plan_id > 0 &&
+ this.isReopenApplicable() && (
+
+
+
+ {T.translate("edit_event.reopen_submission_section")}
+
+ {!this.isSubmissionReopened() && (
+
+
+ {T.translate("edit_event.reopen_duration")}
+
+
+ this.setState({ reopenHours: ev.target.value })
+ }
+ >
+
+ {T.translate("edit_event.reopen_duration_24")}
+
+
+ {T.translate("edit_event.reopen_duration_48")}
+
+
+ {T.translate("edit_event.reopen_duration_72")}
+
+
+ {T.translate("edit_event.reopen_duration_custom")}
+
+
+ {reopenHours === "custom" && (
+ <>
+
+ {maxReopenHours
+ ? T.translate(
+ "edit_event.reopen_custom_hours_capped",
+ { max: maxReopenHours }
+ )
+ : T.translate("edit_event.reopen_custom_hours")}
+
+
+ this.setState({
+ reopenCustomHours: ev.target.value
+ })
+ }
+ />
+ >
+ )}
+
+ {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.close_submission")}
+
+ {window.CFP_APP_BASE_URL && (
+
+
+ {T.translate("edit_event.reopen_deep_link_label")}
+
+
+
+
+ {speakerDeepLink}
+
+ )}
+
+ )}
+
+
+ )}
{T.translate("edit_event.submitter")}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 935ec2de4..d3b35b6e4 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_custom_hours_capped": "Hours (1-{max})",
+ "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"
},
"edit_event_material": {
"material": "Material",
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) => (
+
+ props.onReopenSubmission(42, 24)}>
+ reopen
+
+ props.onCloseSubmission(42)}>
+ close
+
+
+));
+
+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"
+ });
+ });
+});
diff --git a/src/pages/events/edit-summit-event-page.js b/src/pages/events/edit-summit-event-page.js
index 912646db7..716fdbdd6 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";
@@ -241,7 +243,9 @@ function EditSummitEventPage(props) {
getEventFeedbackCSV,
changeFlag,
cloneEvent,
- upgradeEvent
+ upgradeEvent,
+ reopenSubmissionPeriod,
+ closeSubmissionPeriod
} = props;
if (loading) return null;
@@ -311,6 +315,8 @@ function EditSummitEventPage(props) {
getEventFeedbackCSV={getEventFeedbackCSV}
onFlagChange={changeFlag}
onClone={cloneEvent}
+ onReopenSubmission={reopenSubmissionPeriod}
+ onCloseSubmission={closeSubmissionPeriod}
/>
)}
@@ -356,5 +362,7 @@ export default connect(mapStateToProps, {
getEventComments,
deleteEventComment,
cloneEvent,
- upgradeEvent
+ upgradeEvent,
+ reopenSubmissionPeriod,
+ closeSubmissionPeriod
})(EditSummitEventPage);
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 447ae7a74..b52a6f3b4 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,35 @@ const summitEventReducer = (state = DEFAULT_STATE, action) => {
const entity = normalizeEventResponse(payload.response);
return { ...state, entity, errors: {} };
}
+ 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: {
+ ...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: {
+ // Same guard as the reopen case above.
+ if (payload.eventId !== state.entity.id) return state;
+ 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(
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;