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() && ( +
+
+ + {!this.isSubmissionReopened() && ( +
+ + + {reopenHours === "custom" && ( + <> + + + this.setState({ + reopenCustomHours: ev.target.value + }) + } + /> + + )} + +
+ )} + {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 && ( + + +   + +   + {speakerDeepLink} + + )} +
+ )} +
+
+ )}
  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) => ( +
+ + +
+)); + +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;