diff --git a/src/actions/__tests__/marketing-actions.test.js b/src/actions/__tests__/marketing-actions.test.js new file mode 100644 index 000000000..a40243385 --- /dev/null +++ b/src/actions/__tests__/marketing-actions.test.js @@ -0,0 +1,66 @@ +/** + * @jest-environment jsdom + */ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import flushPromises from "flush-promises"; +import { getRequest } from "openstack-uicore-foundation/lib/utils/actions"; +import { getMarketingSettingsBySelectionPlan } from "../marketing-actions"; + +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + __esModule: true, + ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), + getRequest: jest.fn() +})); + +const storeState = { + currentSummitState: { currentSummit: { id: 1 } } +}; + +describe("getMarketingSettingsBySelectionPlan - stale response guard", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("drops an older plan's settings response after a newer plan's response already landed", async () => { + const resolvers = {}; + getRequest.mockImplementation( + (requestActionCreator, receiveActionCreator) => + (params) => + (dispatch) => { + const { selection_plan_id: id } = params; + return new Promise((resolve) => { + resolvers[id] = () => { + if (requestActionCreator) dispatch(requestActionCreator({})); + dispatch(receiveActionCreator({ response: { id } })); + resolve(); + }; + }); + } + ); + + const store = mockStore(storeState); + + // User opens plan 5, then quickly navigates to plan 8 before plan 5's + // settings fetch settles - both requests are genuinely in flight when + // plan 8's response lands first. + store.dispatch(getMarketingSettingsBySelectionPlan("5")); + await flushPromises(); + store.dispatch(getMarketingSettingsBySelectionPlan("8")); + await flushPromises(); + resolvers[8](); + await flushPromises(); + resolvers[5](); + await flushPromises(); + + const receivedIds = store + .getActions() + .filter((a) => a.type === "RECEIVE_SELECTION_PLAN_SETTINGS") + .map((a) => a.payload.response.id); + + expect(receivedIds).toEqual(["8"]); + }); +}); diff --git a/src/actions/__tests__/selection-plan-actions.test.js b/src/actions/__tests__/selection-plan-actions.test.js index 8253ac84b..746848013 100644 --- a/src/actions/__tests__/selection-plan-actions.test.js +++ b/src/actions/__tests__/selection-plan-actions.test.js @@ -6,16 +6,22 @@ import thunk from "redux-thunk"; import flushPromises from "flush-promises"; import { postRequest, - putRequest + putRequest, + getRequest } from "openstack-uicore-foundation/lib/utils/actions"; -import { saveSelectionPlan } from "../selection-plan-actions"; +import { + saveSelectionPlan, + getSelectionPlan, + resetSelectionPlanForm +} from "../selection-plan-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"), postRequest: jest.fn(), - putRequest: jest.fn() + putRequest: jest.fn(), + getRequest: jest.fn() })); jest.mock("../marketing-actions", () => ({ @@ -137,3 +143,104 @@ describe("saveSelectionPlan", () => { }); }); }); + +describe("getSelectionPlan - stale response guard", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + // Only the primary "/selection-plans/{id}" fetch is held open (its + // resolution order is controlled from the test); the allowed-members and + // progress-flags follow-up calls resolve immediately so `await`s in + // getSelectionPlan don't hang. + const isPrimaryFetchUrl = (url) => /\/selection-plans\/[^/]+$/.test(url); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("drops an older plan's response after a newer plan's response already landed", async () => { + const resolvers = {}; + getRequest.mockImplementation( + (requestActionCreator, receiveActionCreator, url) => () => (dispatch) => { + if (isPrimaryFetchUrl(url)) { + const id = Number(url.split("/").pop()); + return new Promise((resolve) => { + resolvers[id] = () => { + dispatch(receiveActionCreator({ response: { id } })); + resolve(); + }; + }); + } + if (requestActionCreator) dispatch(requestActionCreator({})); + dispatch(receiveActionCreator({ response: {} })); + return Promise.resolve(); + } + ); + + const store = mockStore(storeState); + + // User opens plan 5, then quickly navigates to plan 8 before plan 5's + // fetch settles - both requests are genuinely in flight when plan 8's + // response lands first. + store.dispatch(getSelectionPlan("5")); + await flushPromises(); + store.dispatch(getSelectionPlan("8")); + await flushPromises(); + + // The newer request (plan 8) resolves first... + resolvers[8](); + await flushPromises(); + + // ...then the older, superseded request (plan 5) resolves late. + resolvers[5](); + await flushPromises(); + + const receivedIds = store + .getActions() + .filter((a) => a.type === "RECEIVE_SELECTION_PLAN") + .map((a) => a.payload.response.id); + + // Plan 5's stale response must never reach the store - only plan 8's. + expect(receivedIds).toEqual([8]); + }); + + it("drops a plan's response that lands after resetSelectionPlanForm supersedes it", async () => { + const resolvers = {}; + getRequest.mockImplementation( + (requestActionCreator, receiveActionCreator, url) => () => (dispatch) => { + if (isPrimaryFetchUrl(url)) { + const id = Number(url.split("/").pop()); + return new Promise((resolve) => { + resolvers[id] = () => { + dispatch(receiveActionCreator({ response: { id } })); + resolve(); + }; + }); + } + if (requestActionCreator) dispatch(requestActionCreator({})); + dispatch(receiveActionCreator({ response: {} })); + return Promise.resolve(); + } + ); + + const store = mockStore(storeState); + + // User opens plan 5, then navigates back to the list and clicks + // "Add new" before plan 5's fetch settles. + store.dispatch(getSelectionPlan("5")); + await flushPromises(); + store.dispatch(resetSelectionPlanForm()); + await flushPromises(); + resolvers[5](); + await flushPromises(); + + const actionTypes = store.getActions().map((a) => a.type); + + expect(actionTypes).not.toContain("RECEIVE_SELECTION_PLAN"); + expect(actionTypes).toContain("RESET_SELECTION_PLAN_FORM"); + }); +}); diff --git a/src/actions/marketing-actions.js b/src/actions/marketing-actions.js index 305e83802..83ed0a03d 100644 --- a/src/actions/marketing-actions.js +++ b/src/actions/marketing-actions.js @@ -139,6 +139,29 @@ export const getMarketingSettingsForPrintApp = }); }; +// Sequence-guard (see sequenced()): SelectionPlanIdLayout dispatches a fresh +// getMarketingSettingsBySelectionPlan(id) on every route param change, and +// concurrent calls for different plan ids never abort each other (the +// selection_plan_id lives in the query, and uicore's getRequest only aborts +// an identical URL+query) - a stale response landing after a newer one would +// merge the wrong plan's settings into whatever entity is current at that +// moment. guardedDispatch drops the REQUEST/RECEIVE/loading dispatches from +// a superseded call. +const sequenced = () => { + let seq = 0; + return (dispatch) => { + seq += 1; + const mySeq = seq; + return { + isCurrent: () => mySeq === seq, + guardedDispatch: (action) => { + if (mySeq === seq) dispatch(action); + } + }; + }; +}; +const selectionPlanSettingsSeq = sequenced(); + export const getMarketingSettingsBySelectionPlan = ( selectionPlanId, @@ -151,8 +174,9 @@ export const getMarketingSettingsBySelectionPlan = (dispatch, getState) => { const { currentSummitState } = getState(); const { currentSummit } = currentSummitState; + const { guardedDispatch } = selectionPlanSettingsSeq(dispatch); - dispatch(startLoading()); + guardedDispatch(startLoading()); const params = { page, @@ -176,8 +200,8 @@ export const getMarketingSettingsBySelectionPlan = `${window.MARKETING_API_BASE_URL}/api/public/v1/config-values/all/shows/${currentSummit.id}`, authErrorHandler, { order, orderDir, term } - )(params)(dispatch).then(() => { - dispatch(stopLoading()); + )(params)(guardedDispatch).then(() => { + guardedDispatch(stopLoading()); }); }; diff --git a/src/actions/selection-plan-actions.js b/src/actions/selection-plan-actions.js index 0dfb0687f..8e6ed4b8d 100644 --- a/src/actions/selection-plan-actions.js +++ b/src/actions/selection-plan-actions.js @@ -115,13 +115,39 @@ export const getSelectionPlans = }); }; +// Sequence-guard (see sequenced()): SelectionPlanIdLayout dispatches a fresh +// getSelectionPlan(id) on every route param change, and concurrent calls for +// different plan ids never abort each other - a stale response landing after +// a newer one would overwrite the store's entity with the wrong plan's data, +// which the layout's render guard can never recover from on its own (it only +// compares the store id against the URL id; nothing re-triggers a fetch). +// guardedDispatch drops the RECEIVE/loading dispatches from a superseded call. +const sequenced = () => { + let seq = 0; + return (dispatch) => { + seq += 1; + const mySeq = seq; + return { + isCurrent: () => mySeq === seq, + guardedDispatch: (action) => { + if (mySeq === seq) dispatch(action); + } + }; + }; +}; +const getSelectionPlanSeq = sequenced(); + export const getSelectionPlan = (selectionPlanId) => async (dispatch, getState) => { const { currentSummitState } = getState(); + const { isCurrent, guardedDispatch } = getSelectionPlanSeq(dispatch); const accessToken = await getAccessTokenSafely(); const { currentSummit } = currentSummitState; - dispatch(startLoading()); + // Superseded while awaiting the token -> don't fire a request at all. + if (!isCurrent()) return Promise.resolve(); + + guardedDispatch(startLoading()); const params = { access_token: accessToken, @@ -134,16 +160,22 @@ export const getSelectionPlan = createAction(RECEIVE_SELECTION_PLAN), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}`, snackbarErrorHandler - )(params)(dispatch).then(async () => { - await dispatch(getAllowedMembers(selectionPlanId)); - await dispatch( - getSelectionPlanProgressFlags(currentSummit.id, selectionPlanId) - ); - dispatch(stopLoading()); - }); + )(params)(guardedDispatch) + .then(async () => { + // Superseded while the entity was in flight -> skip the follow-up + // requests entirely rather than let them write stale data too. + if (!isCurrent()) return; + await dispatch(getAllowedMembers(selectionPlanId)); + if (!isCurrent()) return; + await dispatch( + getSelectionPlanProgressFlags(currentSummit.id, selectionPlanId) + ); + }) + .finally(() => guardedDispatch(stopLoading())); }; export const resetSelectionPlanForm = () => (dispatch) => { + getSelectionPlanSeq(dispatch); // invalidates any in-flight getSelectionPlan dispatch(createAction(RESET_SELECTION_PLAN_FORM)({})); }; diff --git a/src/components/buttons/add-new-button-mui.js b/src/components/buttons/add-new-button-mui.js new file mode 100644 index 000000000..1d93db84c --- /dev/null +++ b/src/components/buttons/add-new-button-mui.js @@ -0,0 +1,26 @@ +import React from "react"; +import { withRouter } from "react-router-dom"; +import Button from "@mui/material/Button"; +import AddIcon from "@mui/icons-material/Add"; +import T from "i18n-react"; + +function AddNewButtonMui({ entity, history }) { + if (!entity?.id) return null; + + const handleClick = () => { + history.push("new"); + }; + + return ( + + ); +} + +export default withRouter(AddNewButtonMui); diff --git a/src/components/forms/__tests__/selection-plan-form.test.js b/src/components/forms/__tests__/selection-plan-form.test.js index 35bce9f58..c4767fef4 100644 --- a/src/components/forms/__tests__/selection-plan-form.test.js +++ b/src/components/forms/__tests__/selection-plan-form.test.js @@ -1,8 +1,17 @@ import React from "react"; -import { render, screen, within, waitFor } from "@testing-library/react"; +import { screen, within, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; +import { renderWithRedux } from "../../../utils/test-utils"; import SelectionPlanForm from "../selection-plan-form"; +import { + addAllowedMemberToSelectionPlan, + deleteEventTypeSelectionPlan, + importAllowedMembersCSV, + removeAllowedMemberFromSelectionPlan, + removeTrackGroupFromSelectionPlan, + unassignProgressFlagFromSelectionPlan +} from "../../../actions/selection-plan-actions"; jest.mock("i18n-react/dist/i18n-react", () => ({ __esModule: true, @@ -14,6 +23,11 @@ jest.mock( () => ({ __esModule: true, default: () => null }) ); +jest.mock("../../mui/showConfirmDialog", () => ({ + __esModule: true, + default: jest.fn(() => Promise.resolve(true)) +})); + jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({ __esModule: true, default: ({ data, onDelete }) => ( @@ -91,9 +105,32 @@ jest.mock("../../inputs/many-2-many-dropdown", () => ({ default: () => null })); -jest.mock("../../../actions/selection-plan-actions", () => ({ - querySelectionPlanExtraQuestions: jest.fn() -})); +// Action creators are dispatched through connect's bindActionCreators, and +// redux-mock-store requires every dispatched action to be a plain object - +// each mock must return one instead of the default undefined. +jest.mock("../../../actions/selection-plan-actions", () => { + const mockAction = () => jest.fn(() => ({ type: "MOCK_ACTION" })); + return { + __esModule: true, + querySelectionPlanExtraQuestions: jest.fn(), + addAllowedMemberToSelectionPlan: mockAction(), + addEventTypeSelectionPlan: mockAction(), + addTrackGroupToSelectionPlan: mockAction(), + assignExtraQuestion2SelectionPlan: mockAction(), + assignProgressFlag2SelectionPlan: mockAction(), + deleteEventTypeSelectionPlan: mockAction(), + deleteRatingType: mockAction(), + deleteSelectionPlanExtraQuestion: mockAction(), + getAllowedMembers: mockAction(), + importAllowedMembersCSV: mockAction(), + removeAllowedMemberFromSelectionPlan: mockAction(), + removeTrackGroupFromSelectionPlan: mockAction(), + unassignProgressFlagFromSelectionPlan: mockAction(), + updateProgressFlagOrder: mockAction(), + updateRatingTypeOrder: mockAction(), + updateSelectionPlanExtraQuestionOrder: mockAction() + }; +}); jest.mock("../../../actions/track-chair-actions", () => ({ querySummitProgressFlags: jest.fn() @@ -130,7 +167,11 @@ const newEntity = { track_groups: [], event_types: [], extra_questions: [], + extraQuestionsOrder: "order", + extraQuestionsOrderDir: 1, allowed_presentation_action_types: [], + actionTypesOrder: "order", + actionTypesOrderDir: 1, allowed_presentation_questions: [], allowed_presentation_editable_questions: [], marketing_settings: {} @@ -139,38 +180,24 @@ const newEntity = { // Existing plan entity (tabs shown) const existingEntity = { ...newEntity, id: 42, name: "Spring CFP" }; +const mockHistory = { push: jest.fn() }; + const baseProps = { - entity: newEntity, - errors: {}, - currentSummit: { id: 1, time_zone_id: "UTC", slug: "test-summit" }, - extraQuestionsOrder: "id", - extraQuestionsOrderDir: 1, - actionTypesOrder: "id", - actionTypesOrderDir: 1, - allowedMembers: { data: [], currentPage: 1, lastPage: 1 }, - onSave: jest.fn(() => Promise.resolve()), - onTrackGroupLink: jest.fn(), - onTrackGroupUnLink: jest.fn(), - onAddEventType: jest.fn(), - onDeleteEventType: jest.fn(), - onAddRatingType: jest.fn(), - onEditRatingType: jest.fn(), - onDeleteRatingType: jest.fn(), - onEditExtraQuestion: jest.fn(), - onDeleteExtraQuestion: jest.fn(), - onAddNewExtraQuestion: jest.fn(), - onAssignExtraQuestion2SelectionPlan: jest.fn(), - onAssignProgressFlag2SelectionPlan: jest.fn(), - onUnassignProgressFlag: jest.fn(), - onUpdateProgressFlagOrder: jest.fn(), - onUpdateRatingTypeOrder: jest.fn(), - updateExtraQuestionOrder: jest.fn(), - onImportAllowedMembers: jest.fn(), - onAllowedMemberAdd: jest.fn(), - onAllowedMemberDelete: jest.fn(), - onAllowedMembersPageChange: jest.fn() + history: mockHistory, + onSave: jest.fn(() => Promise.resolve()) }; +const stateFor = ( + entity, + allowedMembers = { data: [], currentPage: 1, lastPage: 1 }, + errors = {} +) => ({ + currentSummitState: { + currentSummit: { id: 1, time_zone_id: "UTC", slug: "test-summit" } + }, + currentSelectionPlanState: { entity, allowedMembers, errors } +}); + // Mirrors the popup - external submit button via form attribute const FormWithButton = (props) => ( <> @@ -182,11 +209,17 @@ const FormWithButton = (props) => ( ); -const renderForm = (overrides = {}) => { - const merged = { ...baseProps, ...overrides }; - // eslint-disable-next-line react/jsx-props-no-spreading - return render(); -}; +const renderForm = ({ + entity = newEntity, + allowedMembers, + errors, + ...props +} = {}) => + renderWithRedux( + // eslint-disable-next-line react/jsx-props-no-spreading + , + { initialState: stateFor(entity, allowedMembers, errors) } + ); const renderExistingForm = (overrides = {}) => renderForm({ entity: existingEntity, ...overrides }); @@ -312,18 +345,19 @@ describe("SelectionPlanForm - track_groups tab", () => { expect(within(panel).getByText("Group A")).toBeInTheDocument(); }); - it("calls onTrackGroupUnLink when delete is clicked", async () => { - const onTrackGroupUnLink = jest.fn(); + it("calls removeTrackGroupFromSelectionPlan when delete is clicked", async () => { renderExistingForm({ entity: { ...existingEntity, track_groups: [{ id: 7, name: "G", description: "" }] - }, - onTrackGroupUnLink + } }); await clickTab("edit_selection_plan.track_groups"); await userEvent.click(screen.getByRole("button", { name: "delete" })); - expect(onTrackGroupUnLink).toHaveBeenCalledWith(existingEntity.id, 7); + expect(removeTrackGroupFromSelectionPlan).toHaveBeenCalledWith( + existingEntity.id, + 7 + ); }); }); @@ -357,15 +391,16 @@ describe("SelectionPlanForm - event_types tab", () => { expect(within(panel).getByText("Presentation")).toBeInTheDocument(); }); - it("calls onDeleteEventType when delete is clicked", async () => { - const onDeleteEventType = jest.fn(); + it("calls deleteEventTypeSelectionPlan when delete is clicked", async () => { renderExistingForm({ - entity: { ...existingEntity, event_types: [{ id: 5, name: "Talk" }] }, - onDeleteEventType + entity: { ...existingEntity, event_types: [{ id: 5, name: "Talk" }] } }); await clickTab("edit_selection_plan.event_types"); await userEvent.click(screen.getByRole("button", { name: "delete" })); - expect(onDeleteEventType).toHaveBeenCalledWith(existingEntity.id, 5); + expect(deleteEventTypeSelectionPlan).toHaveBeenCalledWith( + existingEntity.id, + 5 + ); }); }); @@ -387,30 +422,31 @@ describe("SelectionPlanForm - extra_questions tab", () => { ).toBeInTheDocument(); }); - it("calls onAddNewExtraQuestion when Add button is clicked", async () => { - const onAddNewExtraQuestion = jest.fn(); - renderExistingForm({ onAddNewExtraQuestion }); + it("navigates to the new extra question route when Add button is clicked", async () => { + renderExistingForm(); await clickTab("edit_selection_plan.extra_questions"); await userEvent.click( screen.getByRole("button", { name: "edit_selection_plan.add_extra_questions" }) ); - expect(onAddNewExtraQuestion).toHaveBeenCalledTimes(1); + expect(mockHistory.push).toHaveBeenCalledWith( + `/app/summits/1/selection-plans/${existingEntity.id}/extra-questions/new` + ); }); - it("renders extra questions and calls onEditExtraQuestion on edit", async () => { - const onEditExtraQuestion = jest.fn(); + it("renders extra questions and navigates to edit route on edit", async () => { renderExistingForm({ entity: { ...existingEntity, extra_questions: [{ id: 10, name: "q1", label: "Q One", type: "text" }] - }, - onEditExtraQuestion + } }); await clickTab("edit_selection_plan.extra_questions"); await userEvent.click(screen.getByRole("button", { name: "edit" })); - expect(onEditExtraQuestion).toHaveBeenCalledWith(10); + expect(mockHistory.push).toHaveBeenCalledWith( + `/app/summits/1/selection-plans/${existingEntity.id}/extra-questions/10` + ); }); }); @@ -458,30 +494,31 @@ describe("SelectionPlanForm - track_chair_settings tab", () => { ).toBeInTheDocument(); }); - it("calls onAddRatingType when Add Rating Type is clicked", async () => { - const onAddRatingType = jest.fn(); - renderExistingForm({ onAddRatingType }); + it("navigates to the new rating type route when Add Rating Type is clicked", async () => { + renderExistingForm(); await clickTab("track_chair_settings.title"); await userEvent.click( screen.getByRole("button", { name: "track_chair_settings.add_rating_type" }) ); - expect(onAddRatingType).toHaveBeenCalledTimes(1); + expect(mockHistory.push).toHaveBeenCalledWith( + `/app/summits/1/selection-plans/${existingEntity.id}/rating-types/new` + ); }); - it("renders rating types and calls onEditRatingType on edit", async () => { - const onEditRatingType = jest.fn(); + it("renders rating types and navigates to edit route on edit", async () => { renderExistingForm({ entity: { ...existingEntity, track_chair_rating_types: [{ id: 20, name: "Excellent", weight: 10 }] - }, - onEditRatingType + } }); await clickTab("track_chair_settings.title"); await userEvent.click(screen.getByRole("button", { name: "edit" })); - expect(onEditRatingType).toHaveBeenCalledWith(20); + expect(mockHistory.push).toHaveBeenCalledWith( + `/app/summits/1/selection-plans/${existingEntity.id}/rating-types/20` + ); }); }); @@ -505,18 +542,21 @@ describe("SelectionPlanForm - presentation_action_types tab", () => { ).toBeInTheDocument(); }); - it("renders action types and calls onUnassignProgressFlag on delete", async () => { - const onUnassignProgressFlag = jest.fn(); + it("renders action types and calls unassignProgressFlagFromSelectionPlan on delete", async () => { renderExistingForm({ entity: { ...existingEntity, allowed_presentation_action_types: [{ id: 30, label: "Approve" }] - }, - onUnassignProgressFlag + } }); await clickTab("edit_selection_plan.presentation_action_types"); await userEvent.click(screen.getByRole("button", { name: "delete" })); - expect(onUnassignProgressFlag).toHaveBeenCalledWith(30); + await waitFor(() => + expect(unassignProgressFlagFromSelectionPlan).toHaveBeenCalledWith( + existingEntity.id, + 30 + ) + ); }); }); @@ -525,8 +565,9 @@ describe("SelectionPlanForm - presentation_action_types tab", () => { // --------------------------------------------------------------------------- describe("SelectionPlanForm - allowed_members tab", () => { - const membersProps = { - entity: { ...existingEntity, is_hidden: false }, + const membersEntity = { ...existingEntity, is_hidden: false }; + const membersOverrides = { + entity: membersEntity, allowedMembers: { data: [{ id: 1, email: "user@example.com" }], currentPage: 1, @@ -534,17 +575,18 @@ describe("SelectionPlanForm - allowed_members tab", () => { } }; - it("renders members and calls onAllowedMemberDelete on delete", async () => { - const onAllowedMemberDelete = jest.fn(); - renderExistingForm({ ...membersProps, onAllowedMemberDelete }); + it("renders members and calls removeAllowedMemberFromSelectionPlan on delete", async () => { + renderExistingForm(membersOverrides); await clickTab("edit_selection_plan.allowed_members"); await userEvent.click(screen.getByRole("button", { name: "delete" })); - expect(onAllowedMemberDelete).toHaveBeenCalledWith(existingEntity.id, 1); + expect(removeAllowedMemberFromSelectionPlan).toHaveBeenCalledWith( + membersEntity.id, + 1 + ); }); - it("calls onAllowedMemberAdd when Add is clicked with an email", async () => { - const onAllowedMemberAdd = jest.fn(); - renderExistingForm({ ...membersProps, onAllowedMemberAdd }); + it("calls addAllowedMemberToSelectionPlan when Add is clicked with an email", async () => { + renderExistingForm(membersOverrides); await clickTab("edit_selection_plan.allowed_members"); const panel = document.getElementById("tabpanel-allowed_members"); const emailInput = within(panel).getByRole("textbox"); @@ -552,23 +594,22 @@ describe("SelectionPlanForm - allowed_members tab", () => { await userEvent.click( within(panel).getByRole("button", { name: "general.add" }) ); - expect(onAllowedMemberAdd).toHaveBeenCalledWith( - existingEntity.id, + expect(addAllowedMemberToSelectionPlan).toHaveBeenCalledWith( + membersEntity.id, "new@test.com" ); }); - it("calls onImportAllowedMembers when import modal is confirmed", async () => { - const onImportAllowedMembers = jest.fn(); - renderExistingForm({ ...membersProps, onImportAllowedMembers }); + it("calls importAllowedMembersCSV when import modal is confirmed", async () => { + renderExistingForm(membersOverrides); await clickTab("edit_selection_plan.allowed_members"); const panel = document.getElementById("tabpanel-allowed_members"); await userEvent.click( within(panel).getByRole("button", { name: "edit_selection_plan.import" }) ); await userEvent.click(screen.getByRole("button", { name: "ingest" })); - expect(onImportAllowedMembers).toHaveBeenCalledWith( - existingEntity.id, + expect(importAllowedMembersCSV).toHaveBeenCalledWith( + membersEntity.id, expect.any(File) ); }); diff --git a/src/components/forms/selection-plan-form.js b/src/components/forms/selection-plan-form.js index 416062d9e..58c0ffaa9 100644 --- a/src/components/forms/selection-plan-form.js +++ b/src/components/forms/selection-plan-form.js @@ -12,6 +12,7 @@ * */ import React, { useState, useEffect } from "react"; +import { connect } from "react-redux"; import PropTypes from "prop-types"; import T from "i18n-react/dist/i18n-react"; import { useFormik, FormikProvider } from "formik"; @@ -30,6 +31,25 @@ import TrackChairSettingsTab from "./selection-plan-form/track-chair-settings-ta import PresentationActionTypesTab from "./selection-plan-form/presentation-action-types-tab"; import AllowedMembersTab from "./selection-plan-form/allowed-members-tab"; import CfpSettingsTab from "./selection-plan-form/cfp-settings-tab"; +import showConfirmDialog from "../mui/showConfirmDialog"; +import { + addAllowedMemberToSelectionPlan, + addEventTypeSelectionPlan, + addTrackGroupToSelectionPlan, + assignExtraQuestion2SelectionPlan, + assignProgressFlag2SelectionPlan, + deleteEventTypeSelectionPlan, + deleteRatingType, + deleteSelectionPlanExtraQuestion, + getAllowedMembers, + importAllowedMembersCSV, + removeAllowedMemberFromSelectionPlan, + removeTrackGroupFromSelectionPlan, + unassignProgressFlagFromSelectionPlan, + updateProgressFlagOrder, + updateRatingTypeOrder, + updateSelectionPlanExtraQuestionOrder +} from "../../actions/selection-plan-actions"; const DATE_FIELDS = [ "submission_begin_date", @@ -64,32 +84,25 @@ const SelectionPlanForm = (props) => { entity: propsEntity, errors: propsErrors, currentSummit, - extraQuestionsOrderDir, - extraQuestionsOrder, - actionTypesOrderDir, - actionTypesOrder, allowedMembers, + history, onSave, - onTrackGroupLink, - onTrackGroupUnLink, - onAddEventType, - onDeleteEventType, - onAddRatingType, - onEditRatingType, - onDeleteRatingType, - onEditExtraQuestion, - onDeleteExtraQuestion, - onAddNewExtraQuestion, - onAssignExtraQuestion2SelectionPlan, - onAssignProgressFlag2SelectionPlan, - onUnassignProgressFlag, - onUpdateProgressFlagOrder, - onUpdateRatingTypeOrder, - updateExtraQuestionOrder, - onImportAllowedMembers, - onAllowedMemberAdd, - onAllowedMemberDelete, - onAllowedMembersPageChange + addTrackGroupToSelectionPlan, + removeTrackGroupFromSelectionPlan, + addEventTypeSelectionPlan, + deleteEventTypeSelectionPlan, + deleteSelectionPlanExtraQuestion, + updateSelectionPlanExtraQuestionOrder, + assignExtraQuestion2SelectionPlan, + deleteRatingType, + updateRatingTypeOrder, + assignProgressFlag2SelectionPlan, + unassignProgressFlagFromSelectionPlan, + updateProgressFlagOrder, + addAllowedMemberToSelectionPlan, + removeAllowedMemberFromSelectionPlan, + getAllowedMembers, + importAllowedMembersCSV } = props; const [activeTab, setActiveTab] = useState("main"); @@ -143,6 +156,113 @@ const SelectionPlanForm = (props) => { } }, [formik.values.is_hidden]); + const onUpdateExtraQuestionOrder = (questions, questionId, newOrder) => { + updateSelectionPlanExtraQuestionOrder( + propsEntity.id, + questions, + questionId, + newOrder + ); + }; + + const onEditExtraQuestion = (questionId) => { + history.push( + `/app/summits/${currentSummit.id}/selection-plans/${propsEntity.id}/extra-questions/${questionId}` + ); + }; + + const onAddNewExtraQuestion = () => { + history.push( + `/app/summits/${currentSummit.id}/selection-plans/${propsEntity.id}/extra-questions/new` + ); + }; + + const onDeleteExtraQuestion = async (questionId) => { + const extraQuestion = propsEntity.extra_questions.find( + (t) => t.id === questionId + ); + const isConfirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: `${T.translate( + "edit_selection_plan.extra_question_remove_warning" + )} ${extraQuestion.name}`, + iconType: "warning", + showCancelButton: true, + confirmButtonColor: "error", + confirmButtonText: T.translate("general.yes_delete") + }); + if (isConfirmed) { + deleteSelectionPlanExtraQuestion(propsEntity.id, questionId); + } + }; + + const onAddRatingType = () => { + history.push( + `/app/summits/${currentSummit.id}/selection-plans/${propsEntity.id}/rating-types/new` + ); + }; + + const onEditRatingType = (ratingTypeId) => { + history.push( + `/app/summits/${currentSummit.id}/selection-plans/${propsEntity.id}/rating-types/${ratingTypeId}` + ); + }; + + const onUpdateRatingTypeOrder = (ratingTypes, ratingTypeId, newOrder) => { + updateRatingTypeOrder(propsEntity.id, ratingTypes, ratingTypeId, newOrder); + }; + + const onDeleteRatingType = async (ratingTypeId) => { + const ratingType = propsEntity.track_chair_rating_types.find( + (t) => t.id === ratingTypeId + ); + const isConfirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: `${T.translate("edit_selection_plan.rating_type_remove_warning")} ${ + ratingType.name + }`, + iconType: "warning", + showCancelButton: true, + confirmButtonColor: "error", + confirmButtonText: T.translate("general.yes_delete") + }); + if (isConfirmed) { + deleteRatingType(propsEntity.id, ratingTypeId); + } + }; + + const onUpdateProgressFlagOrder = ( + progressFlags, + progressFlagId, + newOrder + ) => { + updateProgressFlagOrder( + propsEntity.id, + progressFlags, + progressFlagId, + newOrder + ); + }; + + const onUnassignProgressFlag = async (progressFlagId) => { + const ratingType = propsEntity.allowed_presentation_action_types.find( + (t) => t.id === progressFlagId + ); + const isConfirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: `${T.translate( + "edit_selection_plan.presentation_action_type_remove_warning" + )} ${ratingType.label}`, + iconType: "warning", + showCancelButton: true, + confirmButtonColor: "error", + confirmButtonText: T.translate("general.yes_delete") + }); + if (isConfirmed) { + unassignProgressFlagFromSelectionPlan(propsEntity.id, progressFlagId); + } + }; + const isNewPlan = formik.values.id === 0; const tabs = [ @@ -231,27 +351,27 @@ const SelectionPlanForm = (props) => {