Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0bbb721
feat: add reopen and close submission-period thunks
caseylocker Aug 11, 2026
1b82204
feat: offer a reopen submission control on the activity form
caseylocker Aug 11, 2026
3e6fcc5
feat: show the active reopen window, close action and speaker link
caseylocker Aug 11, 2026
3e0c935
refactor: dedupe speaker deep-link URL into a local const
caseylocker Aug 11, 2026
69e57fb
fix: gate CFP reopen on selection plan, disable Reopen w/o valid hours
caseylocker Aug 11, 2026
39a3cdd
fix: reject non-positive custom reopen hours
caseylocker Aug 11, 2026
5de6bce
docs: record why the reopen thunks omit a terminal catch
caseylocker Aug 11, 2026
4e0d009
fix: read the reopen thunks from props on the activity page
caseylocker Aug 11, 2026
355a624
fix: swallow the expected 412 rejection on the reopen handlers
caseylocker Aug 11, 2026
5c51fd1
fix: require a positive integer for custom reopen hours
caseylocker Aug 11, 2026
213b381
test: guard the activity page against forwarding raw action imports
caseylocker Aug 11, 2026
bf6ab5b
fix: ignore a reopen or close response for a different activity
caseylocker Aug 11, 2026
0f439b6
fix: bring the reopen thunks in line with the async-thunk conventions
caseylocker Aug 11, 2026
93867d8
fix: only surface the reopen block once the plan window has ended
caseylocker Aug 11, 2026
d4b97a8
fix: read the reopen helpers' entity from state, not props
caseylocker Aug 11, 2026
d4111e5
feat: cap the reopen hours client side from CFP_MAX_REOPEN_HOURS
caseylocker Aug 12, 2026
66cfb80
fix: reject a reopen duration that overflows the deadline calculation
caseylocker Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
148 changes: 146 additions & 2 deletions src/actions/__tests__/event-actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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) => {
Expand Down Expand Up @@ -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");
});
});
91 changes: 87 additions & 4 deletions src/actions/event-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
showMessage,
showSuccessMessage,
authErrorHandler,
snackbarErrorHandler,
snackbarSuccessHandler,
getCSV,
getRawCSV,
downloadFileByContent,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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"
};

Expand Down Expand Up @@ -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"
};

Expand Down Expand Up @@ -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"
};

Expand Down Expand Up @@ -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"
};

Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading
Loading