diff --git a/src/actions/__tests__/selection-plan-actions.test.js b/src/actions/__tests__/selection-plan-actions.test.js new file mode 100644 index 000000000..a67e63e66 --- /dev/null +++ b/src/actions/__tests__/selection-plan-actions.test.js @@ -0,0 +1,107 @@ +/** + * @jest-environment jsdom + */ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import flushPromises from "flush-promises"; +import { + postRequest, + putRequest +} from "openstack-uicore-foundation/lib/utils/actions"; +import { saveSelectionPlan } 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() +})); + +jest.mock("../marketing-actions", () => ({ + saveMarketingSetting: jest.fn() +})); + +const requestMock = + (requestActionCreator, receiveActionCreator) => () => (dispatch) => { + if (requestActionCreator && typeof requestActionCreator === "function") { + dispatch(requestActionCreator({})); + } + return new Promise((resolve) => { + if (typeof receiveActionCreator === "function") { + dispatch(receiveActionCreator({ response: { id: 1 } })); + } else { + dispatch(receiveActionCreator); + } + resolve({ response: { id: 1 } }); + }); + }; + +const storeState = { + currentSummitState: { currentSummit: { id: 1 } } +}; + +describe("saveSelectionPlan", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + postRequest.mockImplementation(requestMock); + putRequest.mockImplementation(requestMock); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("create path (entity has no id)", () => { + it("returns a Promise that resolves with the response payload", async () => { + const store = mockStore(storeState); + const result = store.dispatch( + saveSelectionPlan({ name: "CFP 2026", is_enabled: true }) + ); + expect(result).toBeInstanceOf(Promise); + await expect(result).resolves.toEqual({ id: 1 }); + }); + + it("dispatches SELECTION_PLAN_ADDED then STOP_LOADING on success", async () => { + const store = mockStore(storeState); + store.dispatch(saveSelectionPlan({ name: "CFP 2026", is_enabled: true })); + await flushPromises(); + + const actionTypes = store.getActions().map((a) => a.type); + expect(actionTypes).toContain("SELECTION_PLAN_ADDED"); + expect(actionTypes).toContain("STOP_LOADING"); + expect(actionTypes.indexOf("STOP_LOADING")).toBeGreaterThan( + actionTypes.indexOf("SELECTION_PLAN_ADDED") + ); + }); + }); + + describe("update path (entity has id)", () => { + it("returns a Promise that resolves with the response payload", async () => { + const store = mockStore(storeState); + const result = store.dispatch( + saveSelectionPlan({ id: 1, name: "CFP 2026", is_enabled: true }) + ); + expect(result).toBeInstanceOf(Promise); + await expect(result).resolves.toEqual({ id: 1 }); + }); + + it("dispatches SELECTION_PLAN_UPDATED then STOP_LOADING on success", async () => { + const store = mockStore(storeState); + store.dispatch( + saveSelectionPlan({ id: 1, name: "CFP 2026", is_enabled: true }) + ); + await flushPromises(); + + const actionTypes = store.getActions().map((a) => a.type); + expect(actionTypes).toContain("SELECTION_PLAN_UPDATED"); + expect(actionTypes).toContain("STOP_LOADING"); + expect(actionTypes.indexOf("STOP_LOADING")).toBeGreaterThan( + actionTypes.indexOf("SELECTION_PLAN_UPDATED") + ); + }); + }); +}); diff --git a/src/actions/selection-plan-actions.js b/src/actions/selection-plan-actions.js index e0e48ae77..f41aeb1ad 100644 --- a/src/actions/selection-plan-actions.js +++ b/src/actions/selection-plan-actions.js @@ -12,7 +12,7 @@ * */ import T from "i18n-react/dist/i18n-react"; -import debounce from "lodash/debounce" +import debounce from "lodash/debounce"; import { getRequest, putRequest, @@ -22,8 +22,8 @@ import { stopLoading, startLoading, showMessage, - showSuccessMessage, authErrorHandler, + snackbarErrorHandler, postFile } from "openstack-uicore-foundation/lib/utils/actions"; import URI from "urijs"; @@ -35,7 +35,13 @@ import { fetchErrorHandler } from "../utils/methods"; import { saveMarketingSetting } from "./marketing-actions"; -import { DEBOUNCE_WAIT, DEFAULT_PER_PAGE } from "../utils/constants"; +import { snackbarSuccessHandler } from "./base-actions"; +import { + DEBOUNCE_WAIT, + DEFAULT_CURRENT_PAGE, + DEFAULT_ORDER_DIR, + DEFAULT_PER_PAGE +} from "../utils/constants"; URI.escapeQuerySpace = false; @@ -66,7 +72,13 @@ export const SELECTION_PLAN_PROGRESS_FLAG_ORDER_UPDATED = "SELECTION_PLAN_PROGRESS_FLAG_ORDER_UPDATED"; export const getSelectionPlans = - (term = "", page = 1, order = "id", orderDir = 1) => + ( + term = "", + page = DEFAULT_CURRENT_PAGE, + perPage = DEFAULT_PER_PAGE, + order = "id", + orderDir = DEFAULT_ORDER_DIR + ) => async (dispatch, getState) => { const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); @@ -84,7 +96,7 @@ export const getSelectionPlans = access_token: accessToken, relations: "none", page, - per_page: DEFAULT_PER_PAGE, + per_page: perPage, order: `${orderDir === 1 ? "" : "-"}${order}` }; @@ -93,10 +105,11 @@ export const getSelectionPlans = } return getRequest( - null, + createAction(REQUEST_SELECTION_PLANS), createAction(RECEIVE_SELECTION_PLANS), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans`, - authErrorHandler + authErrorHandler, + { order, orderDir, page, perPage, term } )(params)(dispatch).then(async () => { dispatch(stopLoading()); }); @@ -120,7 +133,7 @@ export const getSelectionPlan = null, createAction(RECEIVE_SELECTION_PLAN), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}`, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(async () => { await dispatch(getAllowedMembers(selectionPlanId)); await dispatch( @@ -149,34 +162,42 @@ export const saveSelectionPlan = (entity) => async (dispatch, getState) => { createAction(SELECTION_PLAN_UPDATED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${entity.id}?access_token=${accessToken}`, normalizedEntity, - authErrorHandler, + snackbarErrorHandler, entity - )({})(dispatch).then((payload) => { - dispatch(stopLoading()); - dispatch( - showSuccessMessage( - T.translate("edit_selection_plan.selection_plan_saved") - ) - ); - return payload.response; - }); + )({})(dispatch) + .then((payload) => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate("edit_selection_plan.selection_plan_saved") + }) + ); + return payload.response; + }) + .finally(() => { + dispatch(stopLoading()); + }); } return postRequest( createAction(UPDATE_SELECTION_PLAN), createAction(SELECTION_PLAN_ADDED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans?access_token=${accessToken}`, normalizedEntity, - authErrorHandler, + snackbarErrorHandler, entity - )({})(dispatch).then((payload) => { - dispatch(stopLoading()); - dispatch( - showSuccessMessage( - T.translate("edit_selection_plan.selection_plan_created") - ) - ); - return payload.response; - }); + )({})(dispatch) + .then((payload) => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate("edit_selection_plan.selection_plan_created") + }) + ); + return payload.response; + }) + .finally(() => { + dispatch(stopLoading()); + }); }; export const deleteSelectionPlan = @@ -194,7 +215,7 @@ export const deleteSelectionPlan = createAction(SELECTION_PLAN_DELETED)({ selectionPlanId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -240,7 +261,7 @@ export const removeTrackGroupFromSelectionPlan = createAction(TRACK_GROUP_REMOVED)({ trackGroupId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/track-groups/${trackGroupId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -404,7 +425,7 @@ export const saveSelectionPlanExtraQuestion = createAction(SELECTION_PLAN_EXTRA_QUESTION_UPDATED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions/${entity.id}`, normalizedEntity, - authErrorHandler, + snackbarErrorHandler, entity )(params)(dispatch).then((payload) => { dispatch(stopLoading()); @@ -440,7 +461,7 @@ export const saveSelectionPlanExtraQuestion = createAction(SELECTION_PLAN_EXTRA_QUESTION_ADDED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions`, normalizedEntity, - authErrorHandler, + snackbarErrorHandler, entity )(params)(dispatch).then((payload) => { dispatch(stopLoading()); @@ -469,7 +490,7 @@ export const deleteSelectionPlanExtraQuestion = createAction(SELECTION_PLAN_EXTRA_QUESTION_DELETED)({ questionId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions/${questionId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -491,7 +512,7 @@ export const updateSelectionPlanExtraQuestionOrder = createAction(SELECTION_PLAN_EXTRA_QUESTION_ORDER_UPDATED)(questions), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions/${questionId}`, { order: newOrder }, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -515,7 +536,7 @@ export const saveSelectionPlanExtraQuestionValue = createAction(SELECTION_PLAN_EXTRA_QUESTION_VALUE_UPDATED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions/${questionId}/values/${entity.id}`, entity, - authErrorHandler, + snackbarErrorHandler, entity )(params)(dispatch).then(() => { dispatch(stopLoading()); @@ -527,7 +548,7 @@ export const saveSelectionPlanExtraQuestionValue = createAction(SELECTION_PLAN_EXTRA_QUESTION_VALUE_ADDED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions/${questionId}/values`, entity, - authErrorHandler, + snackbarErrorHandler, entity )(params)(dispatch).then(() => { dispatch(stopLoading()); @@ -559,7 +580,7 @@ export const updateSelectionPlanExtraQuestionValueOrder = createAction(SELECTION_PLAN_EXTRA_QUESTION_VALUE_UPDATED), `${window.API_BASE_URL}/api/v1/summits/${summit_id}/selection-plans/${selection_plan_id}/extra-questions/${id}/values/${valueId}`, { order: newOrder }, - authErrorHandler, + snackbarErrorHandler, { order: newOrder, id: valueId } )(params)(dispatch).then(() => { dispatch(stopLoading()); @@ -581,7 +602,7 @@ export const deleteSelectionPlanExtraQuestionValue = createAction(SELECTION_PLAN_EXTRA_QUESTION_VALUE_DELETED)({ valueId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions/${questionId}/values/${valueId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -611,7 +632,7 @@ export const addEventTypeSelectionPlan = createAction(SELECTION_PLAN_EVENT_TYPE_ADDED)({ eventType }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/event-types/${eventType.id}`, {}, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -633,7 +654,7 @@ export const deleteEventTypeSelectionPlan = createAction(SELECTION_PLAN_EVENT_TYPE_REMOVED)({ eventTypeId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/event-types/${eventTypeId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -671,7 +692,7 @@ export const updateRatingTypeOrder = createAction(SELECTION_PLAN_RATING_TYPE_ORDER_UPDATED)(ratingTypes), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/track-chair-rating-types/${ratingTypeId}`, ratingType, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -692,7 +713,7 @@ export const deleteRatingType = createAction(SELECTION_PLAN_RATING_TYPE_REMOVED)({ ratingTypeId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/track-chair-rating-types/${ratingTypeId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -724,18 +745,19 @@ export const assignExtraQuestion2SelectionPlan = (summitId, selectionPlanId, questionId) => async (dispatch) => { const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); - postRequest( + return postRequest( null, createAction(SELECTION_PLAN_ASSIGNED_EXTRA_QUESTION), `${window.API_BASE_URL}/api/v1/summits/${summitId}/selection-plans/${selectionPlanId}/extra-questions/${questionId}?access_token=${accessToken}`, {}, - authErrorHandler + snackbarErrorHandler )({})(dispatch).then(() => { dispatch(stopLoading()); dispatch( - showSuccessMessage( - T.translate("edit_selection_plan.selection_plan_saved") - ) + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate("edit_selection_plan.selection_plan_saved") + }) ); }); }; @@ -763,7 +785,7 @@ export const getAllowedMembers = createAction(REQUEST_ALLOWED_MEMBERS), createAction(RECEIVE_ALLOWED_MEMBERS), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/allowed-members`, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -786,7 +808,7 @@ export const addAllowedMemberToSelectionPlan = createAction(ALLOWED_MEMBER_ADDED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/allowed-members`, { email }, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -809,7 +831,7 @@ export const removeAllowedMemberFromSelectionPlan = createAction(ALLOWED_MEMBER_REMOVED)({ emailId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/allowed-members/${emailId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -827,19 +849,22 @@ export const importAllowedMembersCSV = dispatch(startLoading()); - postFile( + return postFile( null, createAction(ALLOWED_MEMBERS_IMPORTED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/allowed-members/csv`, file, {}, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); dispatch( - showSuccessMessage( - T.translate("edit_selection_plan.import_allowed_members_success") - ) + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate( + "edit_selection_plan.import_allowed_members_success" + ) + }) ); }); }; @@ -863,7 +888,7 @@ export const getSelectionPlanProgressFlags = null, createAction(RECEIVE_SELECTION_PLAN_PROGRESS_FLAGS), `${window.API_BASE_URL}/api/v1/summits/${summitId}/selection-plans/${selectionPlanId}/allowed-presentation-action-types`, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -873,18 +898,19 @@ export const assignProgressFlag2SelectionPlan = (summitId, selectionPlanId, progressFlagId) => async (dispatch) => { const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); - postRequest( + return postRequest( null, createAction(SELECTION_PLAN_ASSIGNED_PROGRESS_FLAG), `${window.API_BASE_URL}/api/v1/summits/${summitId}/selection-plans/${selectionPlanId}/allowed-presentation-action-types/${progressFlagId}?access_token=${accessToken}`, {}, - authErrorHandler + snackbarErrorHandler )({})(dispatch).then(() => { dispatch(stopLoading()); dispatch( - showSuccessMessage( - T.translate("edit_selection_plan.selection_plan_saved") - ) + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate("edit_selection_plan.selection_plan_saved") + }) ); }); }; @@ -911,7 +937,7 @@ export const updateProgressFlagOrder = createAction(SELECTION_PLAN_PROGRESS_FLAG_ORDER_UPDATED)(progressFlags), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/allowed-presentation-action-types/${progressFlagId}`, progressFlag, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -932,7 +958,7 @@ export const unassignProgressFlagFromSelectionPlan = createAction(SELECTION_PLAN_PROGRESS_FLAG_REMOVED)({ progressFlagId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/allowed-presentation-action-types/${progressFlagId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); diff --git a/src/components/forms/__tests__/selection-plan-form.test.js b/src/components/forms/__tests__/selection-plan-form.test.js new file mode 100644 index 000000000..35bce9f58 --- /dev/null +++ b/src/components/forms/__tests__/selection-plan-form.test.js @@ -0,0 +1,603 @@ +import React from "react"; +import { render, screen, within, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import SelectionPlanForm from "../selection-plan-form"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/inputs/editor-input-v3", + () => ({ __esModule: true, default: () => null }) +); + +jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({ + __esModule: true, + default: ({ data, onDelete }) => ( + + ) +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/mui/sortable-table", + () => ({ + __esModule: true, + default: ({ data, onEdit, onDelete }) => ( + + ) + }) +); + +jest.mock("openstack-uicore-foundation/lib/utils/query-actions", () => ({ + queryTrackGroups: jest.fn(), + queryEventTypes: jest.fn() +})); + +jest.mock("../../mui/formik-inputs/mui-formik-datetimepicker", () => ({ + __esModule: true, + default: ({ name }) =>
+})); + +jest.mock("../../inputs/email-template-input", () => ({ + __esModule: true, + default: ({ id }) => +})); + +jest.mock("../../inputs/import-modal", () => ({ + __esModule: true, + default: ({ show, onIngest }) => + show ? ( +
+ +
+ ) : null +})); + +jest.mock("../../inputs/many-2-many-dropdown", () => ({ + __esModule: true, + default: () => null +})); + +jest.mock("../../../actions/selection-plan-actions", () => ({ + querySelectionPlanExtraQuestions: jest.fn() +})); + +jest.mock("../../../actions/track-chair-actions", () => ({ + querySummitProgressFlags: jest.fn() +})); + +jest.mock("../../../reducers/selection_plans/selection-plan-reducer", () => ({ + DEFAULT_ALLOWED_EDITABLE_QUESTIONS: [], + DEFAULT_ALLOWED_QUESTIONS: [], + DEFAULT_CFP_PRESENTATION_EDITION_TABS: [] +})); + +// Base entity for a new plan (no tabs shown) +const newEntity = { + id: 0, + name: "", + is_enabled: false, + is_hidden: false, + allow_proposed_schedules: false, + allow_new_presentations: false, + submission_begin_date: null, + submission_end_date: null, + submission_lock_down_presentation_status_date: null, + voting_begin_date: null, + voting_end_date: null, + selection_begin_date: null, + selection_end_date: null, + submission_period_disclaimer: "", + max_submission_allowed_per_user: 0, + presentation_creator_notification_email_template: "", + presentation_moderator_notification_email_template: "", + presentation_speaker_notification_email_template: "", + track_chair_rating_types: [], + allow_track_change_requests: true, + track_groups: [], + event_types: [], + extra_questions: [], + allowed_presentation_action_types: [], + allowed_presentation_questions: [], + allowed_presentation_editable_questions: [], + marketing_settings: {} +}; + +// Existing plan entity (tabs shown) +const existingEntity = { ...newEntity, id: 42, name: "Spring CFP" }; + +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() +}; + +// Mirrors the popup - external submit button via form attribute +const FormWithButton = (props) => ( + <> + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + +); + +const renderForm = (overrides = {}) => { + const merged = { ...baseProps, ...overrides }; + // eslint-disable-next-line react/jsx-props-no-spreading + return render(); +}; + +const renderExistingForm = (overrides = {}) => + renderForm({ entity: existingEntity, ...overrides }); + +const clickTab = async (label) => { + await userEvent.click(screen.getByRole("tab", { name: label })); +}; + +beforeEach(() => jest.clearAllMocks()); + +// --------------------------------------------------------------------------- +// Save behaviour +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - save behaviour", () => { + it("calls onSave when the form is submitted", async () => { + const onSave = jest.fn(() => Promise.resolve()); + renderForm({ onSave }); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + }); + + it("normalizes null dates to 0 before calling onSave", async () => { + const onSave = jest.fn(() => Promise.resolve()); + renderForm({ onSave }); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const [payload] = onSave.mock.calls[0]; + expect(payload.submission_begin_date).toBe(0); + expect(payload.voting_begin_date).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tab navigation +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - tab navigation", () => { + it("hides tabs for a new plan (id=0)", () => { + renderForm(); + expect(screen.queryByRole("tab")).toBeNull(); + }); + + it("shows tab bar for an existing plan (id>0)", () => { + renderExistingForm(); + expect(screen.getByRole("tab", { name: "Main" })).toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: "edit_selection_plan.track_groups" }) + ).toBeInTheDocument(); + }); + + it("hides the allowed_members tab when is_hidden is true", () => { + renderExistingForm({ entity: { ...existingEntity, is_hidden: true } }); + expect( + screen.queryByRole("tab", { name: "edit_selection_plan.allowed_members" }) + ).toBeNull(); + }); + + it("shows the allowed_members tab when is_hidden is false", () => { + renderExistingForm({ entity: { ...existingEntity, is_hidden: false } }); + expect( + screen.getByRole("tab", { name: "edit_selection_plan.allowed_members" }) + ).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Main tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - main tab", () => { + it("renders the name field", () => { + renderForm(); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("renders enabled and hidden checkboxes", () => { + renderForm(); + expect( + screen.getByLabelText("edit_selection_plan.enabled") + ).toBeInTheDocument(); + expect( + screen.getByLabelText("edit_selection_plan.hidden") + ).toBeInTheDocument(); + }); + + it("renders all date pickers", () => { + renderForm(); + expect(screen.getByTestId("submission_begin_date")).toBeInTheDocument(); + expect(screen.getByTestId("submission_end_date")).toBeInTheDocument(); + expect(screen.getByTestId("voting_begin_date")).toBeInTheDocument(); + expect(screen.getByTestId("selection_begin_date")).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Track groups tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - track_groups tab", () => { + const goToTab = async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.track_groups"); + }; + + it("shows empty state when no track groups", async () => { + await goToTab(); + const panel = document.getElementById("tabpanel-track_groups"); + expect( + within(panel).getByText("edit_selection_plan.no_track_groups") + ).toBeInTheDocument(); + }); + + it("renders linked track groups in table", async () => { + renderExistingForm({ + entity: { + ...existingEntity, + track_groups: [{ id: 1, name: "Group A", description: "" }] + } + }); + await clickTab("edit_selection_plan.track_groups"); + const panel = document.getElementById("tabpanel-track_groups"); + expect(within(panel).getByText("Group A")).toBeInTheDocument(); + }); + + it("calls onTrackGroupUnLink when delete is clicked", async () => { + const onTrackGroupUnLink = jest.fn(); + 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); + }); +}); + +// --------------------------------------------------------------------------- +// Event types tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - event_types tab", () => { + const goToTab = async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.event_types"); + }; + + it("shows empty state when no event types", async () => { + await goToTab(); + const panel = document.getElementById("tabpanel-event_types"); + expect( + within(panel).getByText("edit_selection_plan.no_event_types") + ).toBeInTheDocument(); + }); + + it("renders linked event types in table", async () => { + renderExistingForm({ + entity: { + ...existingEntity, + event_types: [{ id: 3, name: "Presentation" }] + } + }); + await clickTab("edit_selection_plan.event_types"); + const panel = document.getElementById("tabpanel-event_types"); + expect(within(panel).getByText("Presentation")).toBeInTheDocument(); + }); + + it("calls onDeleteEventType when delete is clicked", async () => { + const onDeleteEventType = jest.fn(); + renderExistingForm({ + entity: { ...existingEntity, event_types: [{ id: 5, name: "Talk" }] }, + onDeleteEventType + }); + await clickTab("edit_selection_plan.event_types"); + await userEvent.click(screen.getByRole("button", { name: "delete" })); + expect(onDeleteEventType).toHaveBeenCalledWith(existingEntity.id, 5); + }); +}); + +// --------------------------------------------------------------------------- +// Extra questions tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - extra_questions tab", () => { + const goToTab = async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.extra_questions"); + }; + + it("shows empty state when no extra questions", async () => { + await goToTab(); + const panel = document.getElementById("tabpanel-extra_questions"); + expect( + within(panel).getByText("edit_selection_plan.no_extra_questions") + ).toBeInTheDocument(); + }); + + it("calls onAddNewExtraQuestion when Add button is clicked", async () => { + const onAddNewExtraQuestion = jest.fn(); + renderExistingForm({ onAddNewExtraQuestion }); + await clickTab("edit_selection_plan.extra_questions"); + await userEvent.click( + screen.getByRole("button", { + name: "edit_selection_plan.add_extra_questions" + }) + ); + expect(onAddNewExtraQuestion).toHaveBeenCalledTimes(1); + }); + + it("renders extra questions and calls onEditExtraQuestion on edit", async () => { + const onEditExtraQuestion = jest.fn(); + 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); + }); +}); + +// --------------------------------------------------------------------------- +// Email templates tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - email_templates tab", () => { + it("renders the three email template inputs", async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.email_templates"); + const panel = document.getElementById("tabpanel-email_templates"); + expect( + within(panel).getByTestId( + "presentation_creator_notification_email_template" + ) + ).toBeInTheDocument(); + expect( + within(panel).getByTestId( + "presentation_moderator_notification_email_template" + ) + ).toBeInTheDocument(); + expect( + within(panel).getByTestId( + "presentation_speaker_notification_email_template" + ) + ).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Track chair settings tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - track_chair_settings tab", () => { + const goToTab = async () => { + renderExistingForm(); + await clickTab("track_chair_settings.title"); + }; + + it("renders the allow track change requests checkbox", async () => { + await goToTab(); + expect( + screen.getByLabelText("track_chair_settings.allow_change_requests") + ).toBeInTheDocument(); + }); + + it("calls onAddRatingType when Add Rating Type is clicked", async () => { + const onAddRatingType = jest.fn(); + renderExistingForm({ onAddRatingType }); + await clickTab("track_chair_settings.title"); + await userEvent.click( + screen.getByRole("button", { + name: "track_chair_settings.add_rating_type" + }) + ); + expect(onAddRatingType).toHaveBeenCalledTimes(1); + }); + + it("renders rating types and calls onEditRatingType on edit", async () => { + const onEditRatingType = jest.fn(); + 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); + }); +}); + +// --------------------------------------------------------------------------- +// Presentation action types tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - presentation_action_types tab", () => { + const goToTab = async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.presentation_action_types"); + }; + + it("shows empty state when no action types", async () => { + await goToTab(); + const panel = document.getElementById("tabpanel-presentation_action_types"); + expect( + within(panel).getByText( + "edit_selection_plan.no_presentation_action_types" + ) + ).toBeInTheDocument(); + }); + + it("renders action types and calls onUnassignProgressFlag on delete", async () => { + const onUnassignProgressFlag = jest.fn(); + 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); + }); +}); + +// --------------------------------------------------------------------------- +// Allowed members tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - allowed_members tab", () => { + const membersProps = { + entity: { ...existingEntity, is_hidden: false }, + allowedMembers: { + data: [{ id: 1, email: "user@example.com" }], + currentPage: 1, + lastPage: 2 + } + }; + + it("renders members and calls onAllowedMemberDelete on delete", async () => { + const onAllowedMemberDelete = jest.fn(); + renderExistingForm({ ...membersProps, onAllowedMemberDelete }); + await clickTab("edit_selection_plan.allowed_members"); + await userEvent.click(screen.getByRole("button", { name: "delete" })); + expect(onAllowedMemberDelete).toHaveBeenCalledWith(existingEntity.id, 1); + }); + + it("calls onAllowedMemberAdd when Add is clicked with an email", async () => { + const onAllowedMemberAdd = jest.fn(); + renderExistingForm({ ...membersProps, onAllowedMemberAdd }); + await clickTab("edit_selection_plan.allowed_members"); + const panel = document.getElementById("tabpanel-allowed_members"); + const emailInput = within(panel).getByRole("textbox"); + await userEvent.type(emailInput, "new@test.com"); + await userEvent.click( + within(panel).getByRole("button", { name: "general.add" }) + ); + expect(onAllowedMemberAdd).toHaveBeenCalledWith( + existingEntity.id, + "new@test.com" + ); + }); + + it("calls onImportAllowedMembers when import modal is confirmed", async () => { + const onImportAllowedMembers = jest.fn(); + renderExistingForm({ ...membersProps, onImportAllowedMembers }); + 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.any(File) + ); + }); +}); + +// --------------------------------------------------------------------------- +// CFP settings tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - cfp_settings tab", () => { + it("renders the allowed_presentation_questions autocomplete", async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.cfp_settings"); + const panel = document.getElementById("tabpanel-cfp_settings"); + expect( + within(panel).getByPlaceholderText( + "edit_selection_plan.placeholders.allowed_presentation_questions" + ) + ).toBeInTheDocument(); + }); + + it("renders the allowed_presentation_editable_questions autocomplete", async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.cfp_settings"); + const panel = document.getElementById("tabpanel-cfp_settings"); + expect( + within(panel).getByPlaceholderText( + "edit_selection_plan.placeholders.allowed_presentation_editable_questions" + ) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/forms/selection-plan-form.js b/src/components/forms/selection-plan-form.js index eae94c098..416062d9e 100644 --- a/src/components/forms/selection-plan-form.js +++ b/src/components/forms/selection-plan-form.js @@ -11,1466 +11,340 @@ * limitations under the License. * */ -import React from "react"; +import React, { useState, useEffect } from "react"; +import PropTypes from "prop-types"; import T from "i18n-react/dist/i18n-react"; -import "awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css"; +import { useFormik, FormikProvider } from "formik"; +import moment from "moment-timezone"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; -import { - queryTrackGroups, - queryEventTypes, - queryMembers -} from "openstack-uicore-foundation/lib/utils/query-actions"; -import { - Input, - DateTimePicker, - SimpleLinkList, - SortableTable, - Panel, - Table, - Dropdown -} from "openstack-uicore-foundation/lib/components"; -import TextEditorV3 from "openstack-uicore-foundation/lib/components/inputs/editor-input-v3"; -import Switch from "react-switch"; -import { Pagination } from "react-bootstrap"; -import { - isEmpty, - scrollToError, - shallowEqual, - stripTags -} from "../../utils/methods"; -import EmailTemplateInput from "../inputs/email-template-input"; -import ImportModal from "../inputs/import-modal"; -import { - MILLISECONDS_TO_SECONDS, - PresentationTypeClassName -} from "../../utils/constants"; -import Many2ManyDropDown from "../inputs/many-2-many-dropdown"; -import { querySelectionPlanExtraQuestions } from "../../actions/selection-plan-actions"; -import { querySummitProgressFlags } from "../../actions/track-chair-actions"; -import { - DEFAULT_ALLOWED_EDITABLE_QUESTIONS, - DEFAULT_ALLOWED_QUESTIONS, - DEFAULT_CFP_PRESENTATION_EDITION_TABS -} from "../../reducers/selection_plans/selection-plan-reducer"; -import history from "../../history"; - -class SelectionPlanForm extends React.Component { - constructor(props) { - super(props); - - this.state = { - entity: { ...props.entity }, - errors: props.errors, - showSection: "main", - newMemberEmail: "", - showImportModal: false, - importFile: null - }; - - this.handleTrackGroupLink = this.handleTrackGroupLink.bind(this); - this.handleTrackGroupUnLink = this.handleTrackGroupUnLink.bind(this); - this.handleChange = this.handleChange.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - this.handleEditExtraQuestion = this.handleEditExtraQuestion.bind(this); - this.handleDeleteExtraQuestion = this.handleDeleteExtraQuestion.bind(this); - this.handleNewExtraQuestion = this.handleNewExtraQuestion.bind(this); - this.handleDeleteEventType = this.handleDeleteEventType.bind(this); - this.handleAddEventType = this.handleAddEventType.bind(this); - this.handleAddRatingType = this.handleAddRatingType.bind(this); - this.handleDeleteRatingType = this.handleDeleteRatingType.bind(this); - this.handleEditRatingType = this.handleEditRatingType.bind(this); - this.handleRemoveProgressFlag = this.handleRemoveProgressFlag.bind(this); - this.toggleSection = this.toggleSection.bind(this); - this.handleNotificationEmailTemplateChange = - this.handleNotificationEmailTemplateChange.bind(this); - this.fetchSummitSelectionPlanExtraQuestions = - this.fetchSummitSelectionPlanExtraQuestions.bind(this); - this.fetchMembers = this.fetchMembers.bind(this); - this.linkSummitSelectionPlanExtraQuestion = - this.linkSummitSelectionPlanExtraQuestion.bind(this); - this.fetchSummitPresentationActionTypes = - this.fetchSummitPresentationActionTypes.bind(this); - this.linkSummitProgressFlag = this.linkSummitProgressFlag.bind(this); - this.handleAddAllowedMember = this.handleAddAllowedMember.bind(this); - this.handleImportAllowedMembers = - this.handleImportAllowedMembers.bind(this); - this.handleDeleteAllowedMember = this.handleDeleteAllowedMember.bind(this); - this.handleAllowedMembersPageChange = - this.handleAllowedMembersPageChange.bind(this); - this.handleOnSwitchChange = this.handleOnSwitchChange.bind(this); - } - - fetchSummitSelectionPlanExtraQuestions(input, callback) { - const { currentSummit } = this.props; - - if (!input) { - return Promise.resolve({ options: [] }); - } - querySelectionPlanExtraQuestions(currentSummit.id, input, callback); - } - - fetchMembers(input, callback) { - if (!input) { - return Promise.resolve({ options: [] }); - } - queryMembers(input, callback); - } - - linkSummitSelectionPlanExtraQuestion(question) { - const { currentSummit } = this.props; - this.props.onAssignExtraQuestion2SelectionPlan( - currentSummit.id, - this.state.entity.id, - question.id - ); - } - - handleEditExtraQuestion(questionId) { - this.props.onEditExtraQuestion(questionId); - } - - handleDeleteExtraQuestion(questionId) { - this.props.onDeleteExtraQuestion(questionId); - } - - handleNewExtraQuestion() { - this.props.onAddNewExtraQuestion(); - } - - componentDidUpdate(prevProps) { - const state = {}; - scrollToError(this.props.errors); - - if (!shallowEqual(prevProps.entity, this.props.entity)) { - state.entity = { ...this.props.entity }; - state.errors = {}; - } - - if (!shallowEqual(prevProps.errors, this.props.errors)) { - state.errors = { ...this.props.errors }; - } - - if (!isEmpty(state)) { - this.setState({ ...this.state, ...state }); - } - } - - handleChange(ev) { - const newEntity = { ...this.state.entity }; - const newErrors = { ...this.state.errors }; - let { value, id } = ev.target; - - if (ev.target.type === "checkbox") { - value = ev.target.checked; - } - - if (ev.target.type === "datetime") { - value = value.valueOf() / MILLISECONDS_TO_SECONDS; - } - - if (id.startsWith("cfp_")) { - if (!newEntity.marketing_settings.hasOwnProperty(id)) { - newEntity.marketing_settings[id] = { value: "" }; - } - newEntity.marketing_settings[id].value = value; - } else { - newErrors[id] = ""; - newEntity[id] = value; - } - - this.setState({ entity: newEntity, errors: newErrors }); - } - - handleNotificationEmailTemplateChange(ev) { - const newEntity = { ...this.state.entity }; - const newErrors = { ...this.state.errors }; - const { value, id } = ev.target; - - newErrors[id] = ""; - newEntity[id] = value; - this.setState({ ...this.state, entity: newEntity, errors: newErrors }); - } - - handleSubmit(ev) { - ev.preventDefault(); - - const entity = { ...this.state.entity }; - const { currentSummit } = this.props; - - this.props.onSubmit(this.state.entity).then((e) => { - this.props - .saveSelectionPlanSettings(entity.marketing_settings, e.id) - .then(() => { - if (!entity.id) - history.push( - `/app/summits/${currentSummit.id}/selection-plans/${e.id}` - ); - }); +import Box from "@mui/material/Box"; +import Tab from "@mui/material/Tab"; +import Tabs from "@mui/material/Tabs"; +import { scrollToError } from "../../utils/methods"; +import MainTab from "./selection-plan-form/main-tab"; +import TrackGroupsTab from "./selection-plan-form/track-groups-tab"; +import EventTypesTab from "./selection-plan-form/event-types-tab"; +import ExtraQuestionsTab from "./selection-plan-form/extra-questions-tab"; +import EmailTemplatesTab from "./selection-plan-form/email-templates-tab"; +import TrackChairSettingsTab from "./selection-plan-form/track-chair-settings-tab"; +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"; + +const DATE_FIELDS = [ + "submission_begin_date", + "submission_end_date", + "submission_lock_down_presentation_status_date", + "voting_begin_date", + "voting_end_date", + "selection_begin_date", + "selection_end_date" +]; + +const buildInitialValues = (entity, timezone) => { + const values = { ...entity }; + DATE_FIELDS.forEach((field) => { + values[field] = entity[field] + ? epochToMomentTimeZone(entity[field], timezone) + : null; + }); + return values; +}; + +const TAB_SX = { + fontSize: "1.4rem", + lineHeight: "1.8rem", + minHeight: "36px", + px: 2, + py: 1 +}; + +const SelectionPlanForm = (props) => { + const { + entity: propsEntity, + errors: propsErrors, + currentSummit, + extraQuestionsOrderDir, + extraQuestionsOrder, + actionTypesOrderDir, + actionTypesOrder, + allowedMembers, + onSave, + onTrackGroupLink, + onTrackGroupUnLink, + onAddEventType, + onDeleteEventType, + onAddRatingType, + onEditRatingType, + onDeleteRatingType, + onEditExtraQuestion, + onDeleteExtraQuestion, + onAddNewExtraQuestion, + onAssignExtraQuestion2SelectionPlan, + onAssignProgressFlag2SelectionPlan, + onUnassignProgressFlag, + onUpdateProgressFlagOrder, + onUpdateRatingTypeOrder, + updateExtraQuestionOrder, + onImportAllowedMembers, + onAllowedMemberAdd, + onAllowedMemberDelete, + onAllowedMembersPageChange + } = props; + + const [activeTab, setActiveTab] = useState("main"); + + const handleFormikSubmit = (values) => { + const normalized = { ...values }; + DATE_FIELDS.forEach((field) => { + normalized[field] = values[field] + ? moment.tz(values[field], currentSummit.time_zone_id).unix() + : 0; }); - } - - hasErrors(field) { - const { errors } = this.state; - if (field in errors) { - return errors[field]; - } - - return ""; - } - - handleTrackGroupLink(value) { - const { entity } = this.state; - this.props.onTrackGroupLink(entity.id, value); - } - - handleTrackGroupUnLink(valueId) { - const { entity } = this.state; - this.props.onTrackGroupUnLink(entity.id, valueId); - } - - handleAddEventType(value) { - const { entity } = this.state; - this.props.onAddEventType(entity.id, value); - } - - handleDeleteEventType(valueId) { - const { entity } = this.state; - this.props.onDeleteEventType(entity.id, valueId); - } - - handleAddRatingType() { - this.props.onAddRatingType(); - } - - handleEditRatingType(ratingTypeId) { - this.props.onEditRatingType(ratingTypeId); - } - - handleDeleteRatingType(ratingTypeId) { - this.props.onDeleteRatingType(ratingTypeId); - } - - fetchSummitPresentationActionTypes(input, callback) { - const { currentSummit } = this.props; - - if (!input) { - return Promise.resolve({ options: [] }); - } - querySummitProgressFlags(currentSummit.id, input, callback); - } - - linkSummitProgressFlag(progressFlag) { - const { currentSummit } = this.props; - this.props.onAssignProgressFlag2SelectionPlan( - currentSummit.id, - this.state.entity.id, - progressFlag.id + return onSave(normalized); + }; + + const formik = useFormik({ + initialValues: buildInitialValues(propsEntity, currentSummit.time_zone_id), + onSubmit: handleFormikSubmit, + validateOnChange: false + }); + + useEffect(() => { + scrollToError(propsErrors); + formik.setErrors( + propsErrors && Object.keys(propsErrors).length > 0 ? propsErrors : {} ); - } - - handleRemoveProgressFlag(progressFlagId) { - this.props.onUnassignProgressFlag(progressFlagId); - } - - handleImportAllowedMembers(importFile) { - if (importFile) { - this.props.onImportAllowedMembers(this.state.entity.id, importFile); + }, [propsErrors]); + + // Sync sub-resource arrays from Redux without resetting user-editable main tab fields + useEffect(() => { + formik.setValues((current) => ({ + ...current, + track_groups: propsEntity.track_groups ?? [], + event_types: propsEntity.event_types ?? [], + extra_questions: propsEntity.extra_questions ?? [], + track_chair_rating_types: propsEntity.track_chair_rating_types ?? [], + allowed_presentation_action_types: + propsEntity.allowed_presentation_action_types ?? [] + })); + }, [ + propsEntity.track_groups, + propsEntity.event_types, + propsEntity.extra_questions, + propsEntity.track_chair_rating_types, + propsEntity.allowed_presentation_action_types + ]); + + // Reset tab if allowed_members becomes unavailable + useEffect(() => { + if (formik.values.is_hidden && activeTab === "allowed_members") { + setActiveTab("main"); } - this.setState({ ...this.state, showImportModal: false }); - } - - handleAddAllowedMember() { - const { entity, newMemberEmail } = this.state; - this.props.onAllowedMemberAdd(entity.id, newMemberEmail); - } - - handleDeleteAllowedMember(valueId) { - const { entity } = this.state; - this.props.onAllowedMemberDelete(entity.id, valueId); - } - - handleAllowedMembersPageChange(page) { - const { entity } = this.state; - this.props.onAllowedMembersPageChange(entity.id, page); - } - - toggleSection(section) { - const { showSection } = this.state; - const newShowSection = showSection === section ? "main" : section; - this.setState({ showSection: newShowSection }); - } - - handleOnSwitchChange(setting, value) { - const newEntity = { ...this.state.entity }; - const newErrors = { ...this.state.errors }; - - if (!newEntity.marketing_settings.hasOwnProperty(setting)) { - newEntity.marketing_settings[setting] = { value: "" }; + }, [formik.values.is_hidden]); + + const isNewPlan = formik.values.id === 0; + + const tabs = [ + { value: "main", label: "Main" }, + { + value: "track_groups", + label: T.translate("edit_selection_plan.track_groups") + }, + { + value: "event_types", + label: T.translate("edit_selection_plan.event_types") + }, + { + value: "extra_questions", + label: T.translate("edit_selection_plan.extra_questions") + }, + { + value: "email_templates", + label: T.translate("edit_selection_plan.email_templates") + }, + { + value: "track_chair_settings", + label: T.translate("track_chair_settings.title") + }, + { + value: "presentation_action_types", + label: T.translate("edit_selection_plan.presentation_action_types") + }, + ...(!formik.values.is_hidden + ? [ + { + value: "allowed_members", + label: T.translate("edit_selection_plan.allowed_members") + } + ] + : []), + { + value: "cfp_settings", + label: T.translate("edit_selection_plan.cfp_settings") } + ]; + + return ( + + + + + {!isNewPlan && ( + + setActiveTab(val)} + variant="scrollable" + scrollButtons="auto" + sx={{ + "& .MuiTabScrollButton-root.Mui-disabled": { display: "none" } + }} + > + {tabs.map((tab) => ( + + ))} + + + )} - newEntity.marketing_settings[setting].value = value; - - this.setState({ entity: newEntity, errors: newErrors }); - } - - render() { - const { entity, showSection, newMemberEmail, showImportModal } = this.state; - const { - currentSummit, - extraQuestionsOrderDir, - extraQuestionsOrder, - actionTypesOrderDir, - actionTypesOrder, - allowedMembers - } = this.props; - - const trackGroupsColumns = [ - { columnKey: "name", value: T.translate("edit_selection_plan.name") }, - { - columnKey: "description", - value: T.translate("edit_selection_plan.description") - } - ]; - - const trackGroupsOptions = { - valueKey: "name", - labelKey: "name", - defaultOptions: true, - actions: { - search: (input, callback) => { - queryTrackGroups(currentSummit.id, input, callback); - }, - delete: { onClick: this.handleTrackGroupUnLink }, - add: { onClick: this.handleTrackGroupLink } - } - }; - - const eventTypesColumns = [ - { columnKey: "name", value: T.translate("edit_selection_plan.name") } - ]; - - const eventTypesOptions = { - valueKey: "name", - labelKey: "name", - defaultOptions: true, - actions: { - search: (input, callback) => { - queryEventTypes( - currentSummit.id, - input, - callback, - PresentationTypeClassName - ); - }, - delete: { onClick: this.handleDeleteEventType }, - add: { onClick: this.handleAddEventType } - } - }; - - const extraQuestionColumns = [ - { - columnKey: "type", - value: T.translate("order_extra_question_list.question_type") - }, - { - columnKey: "label", - value: T.translate("order_extra_question_list.visible_question") - }, - { - columnKey: "name", - value: T.translate("order_extra_question_list.question_id") - } - ]; - - const extraQuestionsOptions = { - sortCol: extraQuestionsOrder, - sortDir: extraQuestionsOrderDir, - actions: { - edit: { onClick: this.handleEditExtraQuestion }, - delete: { onClick: this.handleDeleteExtraQuestion } - } - }; - - const ratingTypesColumns = [ - { columnKey: "name", value: T.translate("rating_type_list.name") }, - { columnKey: "weight", value: T.translate("rating_type_list.weight") } - ]; - - const ratingTypesOptions = { - actions: { - edit: { onClick: this.handleEditRatingType }, - delete: { onClick: this.handleDeleteRatingType } - } - }; - - const actionTypesColumns = [ - { columnKey: "label", value: T.translate("progress_flags.label") } - ]; - - const actionTypesOptions = { - sortCol: actionTypesOrder, - sortDir: actionTypesOrderDir, - actions: { - delete: { onClick: this.handleRemoveProgressFlag } - } - }; - - const allowedMembersColumns = [ - { columnKey: "id", value: T.translate("edit_selection_plan.id") }, - { columnKey: "email", value: T.translate("edit_selection_plan.email") } - ]; - - const allowedMembersOptions = { - sortCol: "email", - sortDir: 1, - actions: { - delete: { onClick: this.handleDeleteAllowedMember } - } - }; - - console.log("CHECK...", entity, currentSummit); - - return ( -
- -
-
- - -
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
+ {/* Main tab always in DOM - TextEditorV3 must not remount on tab switch */} +