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..669a138f6 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,7 +22,6 @@ import { stopLoading, startLoading, showMessage, - showSuccessMessage, authErrorHandler, postFile } from "openstack-uicore-foundation/lib/utils/actions"; @@ -35,7 +34,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 +71,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 +95,7 @@ export const getSelectionPlans = access_token: accessToken, relations: "none", page, - per_page: DEFAULT_PER_PAGE, + per_page: perPage, order: `${orderDir === 1 ? "" : "-"}${order}` }; @@ -93,10 +104,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()); }); @@ -151,15 +163,19 @@ export const saveSelectionPlan = (entity) => async (dispatch, getState) => { normalizedEntity, authErrorHandler, 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), @@ -168,15 +184,19 @@ export const saveSelectionPlan = (entity) => async (dispatch, getState) => { normalizedEntity, authErrorHandler, 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 = @@ -733,9 +753,10 @@ export const assignExtraQuestion2SelectionPlan = )({})(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") + }) ); }); }; @@ -837,9 +858,12 @@ export const importAllowedMembersCSV = )(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" + ) + }) ); }); }; @@ -882,9 +906,10 @@ export const assignProgressFlag2SelectionPlan = )({})(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") + }) ); }); }; 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..9dab5e5b4 --- /dev/null +++ b/src/components/forms/__tests__/selection-plan-form.test.js @@ -0,0 +1,258 @@ +import React from "react"; +import { + act, + fireEvent, + render, + screen, + 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", () => ({ + Input: ({ id, value, onChange }) => ( + + ), + SimpleLinkList: () => null, + SortableTable: () => null, + Table: () => null, + UploadInput: () => null +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/mui/search-input", + () => ({ + __esModule: true, + default: () => null + }) +); + +jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({ + __esModule: true, + default: () => null +})); + +jest.mock("../../inputs/email-template-input", () => ({ + __esModule: true, + default: () => null +})); + +jest.mock("../../inputs/import-modal", () => ({ + __esModule: true, + default: () => 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: [] +})); + +const defaultEntity = { + id: 0, + name: "", + is_enabled: false, + is_hidden: false, + allow_proposed_schedules: false, + submission_begin_date: null, + submission_end_date: null, + voting_begin_date: null, + voting_end_date: null, + selection_begin_date: null, + selection_end_date: null, + track_chair_rating_types: [], + extra_questions: [], + allowed_presentation_action_types: [], + allowed_event_types: [], + track_groups: [], + marketing_settings: {}, + allowed_editable_questions: [], + allowed_questions: [], + cfp_presentation_edition_custom_message: "", + cfp_presentations_editable_allowed_status: [] +}; + +const defaultProps = { + entity: defaultEntity, + errors: {}, + currentSummit: { id: 1 }, + extraQuestionsOrder: "id", + extraQuestionsOrderDir: 1, + actionTypesOrder: "id", + actionTypesOrderDir: 1, + allowedMembers: { data: [], total: 0 }, + onSaved: jest.fn(), + onSavingChange: jest.fn(), + saveSelectionPlanSettings: 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(), + onAddProgressFlag: jest.fn(), + onEditProgressFlag: jest.fn() +}; + +// Mirrors the popup: form renders without a button; an external button submits +// via the `form` attribute and tracks saving state via onSavingChange. +const FormWithExternalButton = ({ + onSavingChange: onSavingChangeProp, + ...rest +}) => { + const [saving, setSaving] = React.useState(false); + const handleSavingChange = (s) => { + setSaving(s); + onSavingChangeProp(s); + }; + return ( + <> + + + + ); +}; + +describe("SelectionPlanForm — save guard", () => { + let onSubmit; + let onSavingChange; + + beforeEach(() => { + onSubmit = jest.fn(); + onSavingChange = jest.fn(); + jest.clearAllMocks(); + }); + + const renderForm = (props = {}) => + render( + + ); + + it("disables submit and calls onSavingChange(true) while save is pending", async () => { + let resolveSave; + onSubmit.mockReturnValue( + new Promise((resolve) => { + resolveSave = resolve; + }) + ); + + renderForm(); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "general.save" }) + ).toBeDisabled(); + }); + expect(onSavingChange).toHaveBeenCalledWith(true); + + await act(async () => { + resolveSave({ id: 1 }); + await Promise.resolve(); + }); + }); + + it("does not re-trigger onSubmit while a save is in flight", async () => { + let resolveSave; + onSubmit.mockReturnValue( + new Promise((resolve) => { + resolveSave = resolve; + }) + ); + + renderForm(); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + await waitFor(() => + expect( + screen.getByRole("button", { name: "general.save" }) + ).toBeDisabled() + ); + + // Second click on disabled button — should not fire onSubmit again + fireEvent.click(screen.getByRole("button", { name: "general.save" })); + expect(onSubmit).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveSave({ id: 1 }); + await Promise.resolve(); + }); + }); + + it("re-enables submit and calls onSavingChange(false) when onSubmit rejects", async () => { + onSubmit.mockImplementation(() => Promise.reject(new Error("API error"))); + + renderForm(); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "general.save" }) + ).not.toBeDisabled(); + }); + expect(onSavingChange).toHaveBeenCalledWith(false); + }); + + it("calls saveSelectionPlanSettings then onSaved on successful submit", async () => { + const savedEntity = { id: 42 }; + const onSubmitMock = jest.fn().mockResolvedValue(savedEntity); + const onSavedMock = jest.fn(); + const saveSettingsMock = jest.fn().mockResolvedValue(); + + renderForm({ + onSubmit: onSubmitMock, + onSaved: onSavedMock, + saveSelectionPlanSettings: saveSettingsMock + }); + + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + await waitFor(() => { + expect(saveSettingsMock).toHaveBeenCalledWith( + defaultEntity.marketing_settings, + savedEntity.id + ); + expect(onSavedMock).toHaveBeenCalledWith(savedEntity); + }); + }); +}); diff --git a/src/components/forms/selection-plan-form.js b/src/components/forms/selection-plan-form.js index eae94c098..fe3e6a7f5 100644 --- a/src/components/forms/selection-plan-form.js +++ b/src/components/forms/selection-plan-form.js @@ -11,39 +11,36 @@ * limitations under the License. * */ -import React from "react"; +import React, { useState, useEffect } from "react"; 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 + queryEventTypes } from "openstack-uicore-foundation/lib/utils/query-actions"; -import { - Input, - DateTimePicker, - SimpleLinkList, - SortableTable, - Panel, - Table, - Dropdown -} from "openstack-uicore-foundation/lib/components"; +import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"; +import MuiFormikDatepicker from "openstack-uicore-foundation/lib/components/mui/formik-inputs/datepicker"; +import SortableTable from "openstack-uicore-foundation/lib/components/mui/sortable-table"; +import Table from "openstack-uicore-foundation/lib/components/mui/table"; +import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"; 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 Autocomplete from "@mui/material/Autocomplete"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Grid2 from "@mui/material/Grid2"; +import MuiSwitch from "@mui/material/Switch"; +import Pagination from "@mui/material/Pagination"; +import Tab from "@mui/material/Tab"; +import Tabs from "@mui/material/Tabs"; +import TextField from "@mui/material/TextField"; +import { scrollToError, 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 { 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"; @@ -52,1425 +49,1409 @@ import { 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); +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, + onSaved, + onSavingChange, + onSubmit, + saveSelectionPlanSettings, + 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 [newMemberEmail, setNewMemberEmail] = useState(""); + const [showImportModal, setShowImportModal] = useState(false); + const [trackGroupSelection, setTrackGroupSelection] = useState(null); + const [trackGroupSearchOptions, setTrackGroupSearchOptions] = useState([]); + const [eventTypeSelection, setEventTypeSelection] = useState(null); + const [eventTypeSearchOptions, setEventTypeSearchOptions] = useState([]); + + const handleFormikSubmit = (values) => { + if (onSavingChange) onSavingChange(true); + + const normalized = { ...values }; + DATE_FIELDS.forEach((field) => { + if (values[field]) { + normalized[field] = moment + .tz(values[field], currentSummit.time_zone_id) + .unix(); + } else { + normalized[field] = 0; + } + }); - if (!shallowEqual(prevProps.entity, this.props.entity)) { - state.entity = { ...this.props.entity }; - state.errors = {}; + return onSubmit(normalized) + .then((e) => { + if (!e?.id) return null; + return saveSelectionPlanSettings(values.marketing_settings, e.id).then( + () => { + if (onSaved) onSaved(e); + } + ); + }) + .catch(() => { + // errors are surfaced via error handler + }) + .finally(() => { + if (onSavingChange) onSavingChange(false); + }); + }; + + const formik = useFormik({ + initialValues: buildInitialValues(propsEntity, currentSummit.time_zone_id), + onSubmit: handleFormikSubmit, + enableReinitialize: true, + validateOnChange: false + }); + + useEffect(() => { + scrollToError(propsErrors); + if (propsErrors && Object.keys(propsErrors).length > 0) { + formik.setErrors(propsErrors); } + }, [propsErrors]); - if (!shallowEqual(prevProps.errors, this.props.errors)) { - state.errors = { ...this.props.errors }; + // Reset tab if allowed_members becomes unavailable + useEffect(() => { + if (formik.values.is_hidden && activeTab === "allowed_members") { + setActiveTab("main"); } + }, [formik.values.is_hidden]); - if (!isEmpty(state)) { - this.setState({ ...this.state, ...state }); - } - } + const hasErrors = (field) => formik.errors[field] ?? ""; - handleChange(ev) { - const newEntity = { ...this.state.entity }; - const newErrors = { ...this.state.errors }; + const handleChange = (ev) => { 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; + const current = formik.values.marketing_settings[id] || {}; + formik.setFieldValue(`marketing_settings.${id}`, { ...current, 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}` - ); - }); - }); - } - - hasErrors(field) { - const { errors } = this.state; - if (field in errors) { - return errors[field]; + formik.setFieldValue(id, value); } + }; + + const handleNotificationEmailTemplateChange = (ev) => { + formik.setFieldValue(ev.target.id, ev.target.value); + }; + + const handleTrackGroupLink = (value) => + onTrackGroupLink(formik.values.id, value); + const handleTrackGroupUnLink = (valueId) => + onTrackGroupUnLink(formik.values.id, valueId); + const handleAddEventType = (value) => onAddEventType(formik.values.id, value); + const handleDeleteEventType = (valueId) => + onDeleteEventType(formik.values.id, valueId); + const handleAddRatingType = () => onAddRatingType(); + const handleEditRatingType = (ratingTypeId) => onEditRatingType(ratingTypeId); + const handleDeleteRatingType = (ratingTypeId) => + onDeleteRatingType(ratingTypeId); + const handleEditExtraQuestion = (questionId) => + onEditExtraQuestion(questionId); + const handleDeleteExtraQuestion = (questionId) => + onDeleteExtraQuestion(questionId); + const handleNewExtraQuestion = () => onAddNewExtraQuestion(); + const handleRemoveProgressFlag = (progressFlagId) => + onUnassignProgressFlag(progressFlagId); + const handleDeleteAllowedMember = (valueId) => + onAllowedMemberDelete(formik.values.id, valueId); + const handleAllowedMembersPageChange = (page) => + onAllowedMembersPageChange(formik.values.id, page); + + const handleAddAllowedMember = () => + onAllowedMemberAdd(formik.values.id, newMemberEmail); + + const handleImportAllowedMembers = (importFile) => { + if (importFile) onImportAllowedMembers(formik.values.id, importFile); + setShowImportModal(false); + }; + + const fetchSummitSelectionPlanExtraQuestions = (input, callback) => { + if (!input) return Promise.resolve({ options: [] }); + querySelectionPlanExtraQuestions(currentSummit.id, input, callback); + }; - 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; + const linkSummitSelectionPlanExtraQuestion = (question) => { + onAssignExtraQuestion2SelectionPlan( + currentSummit.id, + formik.values.id, + question.id + ); + }; - if (!input) { - return Promise.resolve({ options: [] }); - } + const fetchSummitPresentationActionTypes = (input, callback) => { + if (!input) return Promise.resolve({ options: [] }); querySummitProgressFlags(currentSummit.id, input, callback); - } + }; - linkSummitProgressFlag(progressFlag) { - const { currentSummit } = this.props; - this.props.onAssignProgressFlag2SelectionPlan( + const linkSummitProgressFlag = (progressFlag) => { + onAssignProgressFlag2SelectionPlan( currentSummit.id, - this.state.entity.id, + formik.values.id, progressFlag.id ); - } + }; - handleRemoveProgressFlag(progressFlagId) { - this.props.onUnassignProgressFlag(progressFlagId); - } + const handleOnSwitchChange = (setting, value) => { + const current = formik.values.marketing_settings[setting] || {}; + formik.setFieldValue(`marketing_settings.${setting}`, { + ...current, + value + }); + }; - handleImportAllowedMembers(importFile) { - if (importFile) { - this.props.onImportAllowedMembers(this.state.entity.id, importFile); + const trackGroupsColumns = [ + { columnKey: "name", header: T.translate("edit_selection_plan.name") }, + { + columnKey: "description", + header: T.translate("edit_selection_plan.description") } - 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: "" }; + ]; + + const trackGroupsOptions = {}; + + const eventTypesColumns = [ + { columnKey: "name", header: T.translate("edit_selection_plan.name") } + ]; + + const eventTypesOptions = {}; + + const extraQuestionColumns = [ + { + columnKey: "type", + header: T.translate("order_extra_question_list.question_type") + }, + { + columnKey: "label", + header: T.translate("order_extra_question_list.visible_question") + }, + { + columnKey: "name", + header: T.translate("order_extra_question_list.question_id") } + ]; + + const extraQuestionsOptions = { + sortCol: extraQuestionsOrder, + sortDir: extraQuestionsOrderDir + }; + + const ratingTypesColumns = [ + { columnKey: "name", header: T.translate("rating_type_list.name") }, + { columnKey: "weight", header: T.translate("rating_type_list.weight") } + ]; + + const ratingTypesOptions = {}; + + const actionTypesColumns = [ + { columnKey: "label", header: T.translate("progress_flags.label") } + ]; + + const actionTypesOptions = { + sortCol: actionTypesOrder, + sortDir: actionTypesOrderDir + }; + + const allowedMembersColumns = [ + { columnKey: "id", header: T.translate("edit_selection_plan.id") }, + { columnKey: "email", header: T.translate("edit_selection_plan.email") } + ]; + + const allowedMembersOptions = { + sortCol: "email", + sortDir: 1 + }; + + 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 ( + + + + + {formik.values.id !== 0 && ( + + 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 ( -
- -
-
- - -
-
-
- + + + + - -
-
-
-
- + + + } + label={T.translate("edit_selection_plan.enabled")} /> - -
-
-
-
- + + + } + label={T.translate("edit_selection_plan.hidden")} /> - -
-
-
-
- + + + } + label={T.translate( + "edit_selection_plan.allow_proposed_schedules" + )} /> - -
-
-
- -
-
- - -
-
- - -
-
-
-
- - -
-
-