diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 78c4e3079..4705fa1ab 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -368,7 +368,9 @@ export const cloneGlobalTemplate = }) ); }) - .catch(() => {}); // need to catch promise reject + .finally(() => { + dispatch(stopLoading()); + }); }; export const saveFormTemplate = (entity) => async (dispatch, getState) => { @@ -403,7 +405,6 @@ export const saveFormTemplate = (entity) => async (dispatch, getState) => { }) ); }) - .catch(() => {}) // need to catch promise reject .finally(() => { dispatch(stopLoading()); }); @@ -443,7 +444,6 @@ export const updateFormTemplate = (entity) => async (dispatch, getState) => { }) ); }) - .catch(() => {}) // need to catch promise reject .finally(() => { dispatch(stopLoading()); }); diff --git a/src/pages/sponsors/sponsor-forms-list-page/components/form-template/__tests__/form-template-popup.test.js b/src/pages/sponsors/sponsor-forms-list-page/components/form-template/__tests__/form-template-popup.test.js new file mode 100644 index 000000000..062fd01eb --- /dev/null +++ b/src/pages/sponsors/sponsor-forms-list-page/components/form-template/__tests__/form-template-popup.test.js @@ -0,0 +1,110 @@ +import React from "react"; +import { act, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithRedux } from "utils/test-utils"; +import { saveFormTemplate } from "actions/sponsor-forms-actions"; +import FormTemplatePopup from "../form-template-popup"; + +jest.mock("actions/sponsor-forms-actions", () => ({ + saveFormTemplate: jest.fn(), + updateFormTemplate: jest.fn(), + getSponsorships: jest.fn(() => ({ type: "GET_SPONSORSHIPS" })), + resetFormTemplate: jest.fn(() => ({ type: "RESET_TEMPLATE_FORM" })) +})); + +jest.mock("../form-template-form", () => ({ + __esModule: true, + default: ({ onSubmit, isSaving }) => ( + + ) +})); + +describe("FormTemplatePopup", () => { + const initialState = { + sponsorFormsListState: { + sponsorships: { items: [] }, + formTemplate: {} + }, + currentSummitState: { + currentSummit: { + time_zone_id: "UTC" + } + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("keeps modal open when save fails", async () => { + const onClose = jest.fn(); + saveFormTemplate.mockReturnValue(() => Promise.reject(new Error("dup"))); + + renderWithRedux(, { + initialState + }); + + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: "submit-form-template" }) + ); + await Promise.resolve(); + }); + + expect(saveFormTemplate).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("prevents duplicate save requests while saving", async () => { + const onClose = jest.fn(); + let resolveSave; + const pendingPromise = new Promise((resolve) => { + resolveSave = resolve; + }); + + saveFormTemplate.mockReturnValue(() => pendingPromise); + + renderWithRedux(, { + initialState + }); + + const button = screen.getByRole("button", { name: "submit-form-template" }); + + await act(async () => { + await userEvent.click(button); + await userEvent.click(button); + }); + + expect(saveFormTemplate).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveSave(); + await Promise.resolve(); + }); + }); + + it("closes modal after successful save", async () => { + const onClose = jest.fn(); + saveFormTemplate.mockReturnValue(() => Promise.resolve()); + + renderWithRedux(, { + initialState + }); + + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: "submit-form-template" }) + ); + await Promise.resolve(); + }); + + expect(saveFormTemplate).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/pages/sponsors/sponsor-forms-list-page/components/form-template/form-template-form.js b/src/pages/sponsors/sponsor-forms-list-page/components/form-template/form-template-form.js index 9f70f4ee4..e26f9afab 100644 --- a/src/pages/sponsors/sponsor-forms-list-page/components/form-template/form-template-form.js +++ b/src/pages/sponsors/sponsor-forms-list-page/components/form-template/form-template-form.js @@ -46,6 +46,7 @@ const FormTemplateForm = ({ initialValues, sponsorships, summitTZ, + isSaving = false, onSubmit }) => { const formik = useFormik({ @@ -150,7 +151,12 @@ const FormTemplateForm = ({ - diff --git a/src/pages/sponsors/sponsor-forms-list-page/components/form-template/form-template-popup.js b/src/pages/sponsors/sponsor-forms-list-page/components/form-template/form-template-popup.js index 160c03546..952002057 100644 --- a/src/pages/sponsors/sponsor-forms-list-page/components/form-template/form-template-popup.js +++ b/src/pages/sponsors/sponsor-forms-list-page/components/form-template/form-template-popup.js @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import PropTypes from "prop-types"; import T from "i18n-react/dist/i18n-react"; import { connect } from "react-redux"; @@ -31,33 +31,52 @@ const FormTemplatePopup = ({ updateFormTemplate, edit }) => { + const [isSaving, setIsSaving] = useState(false); + useEffect(() => { getSponsorships(1, MAX_PER_PAGE); }, []); - const handleClose = () => { - // clear form from reducer + const closePopup = () => { resetFormTemplate(); onClose(); }; + const handleClose = () => { + if (isSaving) return; + closePopup(); + }; + const handleOnSave = (values) => { + if (isSaving) return; + const save = values.id ? updateFormTemplate : saveFormTemplate; + setIsSaving(true); - save(values).finally(() => { - handleClose(); - }); + save(values) + .then(() => { + closePopup(); + }) + .catch(() => { + // keep dialog open on save error to preserve user input + }) + .finally(() => { + setIsSaving(false); + }); }; return ( { + handleClose(); + }} maxWidth="md" fullWidth disableEnforceFocus disableAutoFocus disableRestoreFocus + disableEscapeKeyDown={isSaving} > - + @@ -79,6 +103,7 @@ const FormTemplatePopup = ({ initialValues={formTemplate} sponsorships={sponsorships} summitTZ={summitTZ} + isSaving={isSaving} onSubmit={handleOnSave} /> diff --git a/src/pages/sponsors/sponsor-forms-list-page/components/global-template/__tests__/global-template-popup.test.js b/src/pages/sponsors/sponsor-forms-list-page/components/global-template/__tests__/global-template-popup.test.js new file mode 100644 index 000000000..3b6c2c58c --- /dev/null +++ b/src/pages/sponsors/sponsor-forms-list-page/components/global-template/__tests__/global-template-popup.test.js @@ -0,0 +1,143 @@ +import React from "react"; +import { act, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithRedux } from "utils/test-utils"; +import { cloneGlobalTemplate } from "actions/sponsor-forms-actions"; +import GlobalTemplatePopup from "../global-template-popup"; + +jest.mock("@mui/material", () => { + const originalModule = jest.requireActual("@mui/material"); + return { + ...originalModule, + Dialog: ({ open, onClose, children }) => + open ? ( +
+ + {children} +
+ ) : null + }; +}); + +jest.mock("actions/sponsor-forms-actions", () => ({ + cloneGlobalTemplate: jest.fn() +})); + +jest.mock("../select-templates-dialog", () => ({ + __esModule: true, + default: ({ onSave }) => ( +
+ +
+ ) +})); + +jest.mock("../select-sponsorships-dialog", () => ({ + __esModule: true, + default: ({ onSave, isSaving }) => ( +
+ sponsorships-step + +
+ ) +})); + +describe("GlobalTemplatePopup", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("keeps popup open on clone error", async () => { + const onClose = jest.fn(); + cloneGlobalTemplate.mockReturnValue(() => Promise.reject(new Error("dup"))); + + renderWithRedux(, { + initialState: {} + }); + + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: "go-sponsorships" }) + ); + await userEvent.click( + screen.getByRole("button", { name: "apply-sponsorships" }) + ); + await Promise.resolve(); + }); + + expect(cloneGlobalTemplate).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByText("sponsorships-step")).toBeTruthy(); + }); + + it("resets stage to templates on dialog onClose", async () => { + const onClose = jest.fn(); + + renderWithRedux(, { + initialState: {} + }); + + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: "go-sponsorships" }) + ); + }); + expect(screen.getByText("sponsorships-step")).toBeTruthy(); + + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: "dialog-onclose" }) + ); + await Promise.resolve(); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect( + screen.queryByRole("button", { name: "go-sponsorships" }) + ).not.toBeInTheDocument(); + }); + + it("prevents duplicate clone requests while saving", async () => { + const onClose = jest.fn(); + let resolveClone; + const pendingPromise = new Promise((resolve) => { + resolveClone = resolve; + }); + cloneGlobalTemplate.mockReturnValue(() => pendingPromise); + + renderWithRedux(, { + initialState: {} + }); + + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: "go-sponsorships" }) + ); + }); + + const applyButton = screen.getByRole("button", { + name: "apply-sponsorships" + }); + await act(async () => { + await userEvent.click(applyButton); + await userEvent.click(applyButton); + }); + + expect(cloneGlobalTemplate).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveClone(); + await Promise.resolve(); + }); + }); +}); diff --git a/src/pages/sponsors/sponsor-forms-list-page/components/global-template/global-template-popup.js b/src/pages/sponsors/sponsor-forms-list-page/components/global-template/global-template-popup.js index 184dead62..7e3f87c35 100644 --- a/src/pages/sponsors/sponsor-forms-list-page/components/global-template/global-template-popup.js +++ b/src/pages/sponsors/sponsor-forms-list-page/components/global-template/global-template-popup.js @@ -2,36 +2,60 @@ import React, { useState } from "react"; import PropTypes from "prop-types"; import { connect } from "react-redux"; import { Dialog } from "@mui/material"; +import { cloneGlobalTemplate } from "../../../../../actions/sponsor-forms-actions"; import SelectTemplatesDialog from "./select-templates-dialog"; import SelectSponsorshipsDialog from "./select-sponsorships-dialog"; -import { cloneGlobalTemplate } from "../../../../../actions/sponsor-forms-actions"; const GlobalTemplatePopup = ({ open, onClose, cloneGlobalTemplate }) => { const [stage, setStage] = useState("templates"); const [selectedTemplates, setSelectedTemplates] = useState([]); + const [isSaving, setIsSaving] = useState(false); const dialogSize = stage === "templates" ? "md" : "sm"; const handleClose = () => { + if (isSaving) return; setSelectedTemplates([]); setStage("templates"); onClose(); }; + const handleDismiss = () => { + if (isSaving) return; + onClose(); + }; + const handleOnSelectTemplates = (templates) => { setSelectedTemplates(templates); setStage("sponsorships"); }; const handleOnSave = (selectedTiers, allTiers) => { - cloneGlobalTemplate(selectedTemplates, selectedTiers, allTiers).finally( - () => { - handleClose(); - } - ); + if (isSaving) return; + + setIsSaving(true); + + cloneGlobalTemplate(selectedTemplates, selectedTiers, allTiers) + .then(() => { + setSelectedTemplates([]); + setStage("templates"); + onClose(); + }) + .catch(() => { + // keep dialog open on save error to preserve user progress + }) + .finally(() => { + setIsSaving(false); + }); }; return ( - + {stage === "templates" && ( { /> )} {stage === "sponsorships" && ( - + )} ); diff --git a/src/pages/sponsors/sponsor-forms-list-page/components/global-template/select-sponsorships-dialog.js b/src/pages/sponsors/sponsor-forms-list-page/components/global-template/select-sponsorships-dialog.js index 1fc798813..c26ee9518 100644 --- a/src/pages/sponsors/sponsor-forms-list-page/components/global-template/select-sponsorships-dialog.js +++ b/src/pages/sponsors/sponsor-forms-list-page/components/global-template/select-sponsorships-dialog.js @@ -20,6 +20,7 @@ const SelectSponsorshipsDialog = ({ sponsorships, onSave, onClose, + isSaving = false, getSponsorships }) => { const { items, currentPage, total } = sponsorships; @@ -36,6 +37,7 @@ const SelectSponsorshipsDialog = ({ }; const handleClose = () => { + if (isSaving) return; setSelection({ ids: [], all: false }); onClose(); }; @@ -83,7 +85,7 @@ const SelectSponsorshipsDialog = ({ + ) +})); + +describe("CustomizedFormPopup", () => { + const initialState = { + currentSummitState: { + currentSummit: { + time_zone_id: "UTC" + } + }, + sponsorCustomizedFormState: { + entity: {} + } + }; + + const sponsor = { + id: 1, + sponsorships_collection: { + sponsorships: [] + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("keeps modal open when save fails", async () => { + const onClose = jest.fn(); + const onSaved = jest.fn(); + saveSponsorCustomizedForm.mockReturnValue(() => + Promise.reject(new Error("dup")) + ); + + renderWithRedux( + , + { initialState } + ); + + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: "submit-customized-form" }) + ); + await Promise.resolve(); + }); + + expect(saveSponsorCustomizedForm).toHaveBeenCalledTimes(1); + expect(onSaved).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("prevents duplicate save requests while saving", async () => { + const onClose = jest.fn(); + let resolveSave; + const pendingPromise = new Promise((resolve) => { + resolveSave = resolve; + }); + + saveSponsorCustomizedForm.mockReturnValue(() => pendingPromise); + + renderWithRedux( + , + { initialState } + ); + + const button = screen.getByRole("button", { + name: "submit-customized-form" + }); + await act(async () => { + await userEvent.click(button); + await userEvent.click(button); + }); + + expect(saveSponsorCustomizedForm).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveSave(); + await Promise.resolve(); + }); + }); +}); diff --git a/src/pages/sponsors/sponsor-forms-tab/components/customized-form/customized-form-popup.js b/src/pages/sponsors/sponsor-forms-tab/components/customized-form/customized-form-popup.js index 672da5a40..1597913a9 100644 --- a/src/pages/sponsors/sponsor-forms-tab/components/customized-form/customized-form-popup.js +++ b/src/pages/sponsors/sponsor-forms-tab/components/customized-form/customized-form-popup.js @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import PropTypes from "prop-types"; import T from "i18n-react/dist/i18n-react"; import { connect } from "react-redux"; @@ -26,25 +26,43 @@ const CustomizedFormPopup = ({ summitTZ, open, onClose, + onSaved, getSponsorCustomizedForm, resetSponsorCustomizedForm, saveSponsorCustomizedForm, updateSponsorCustomizedForm }) => { - const handleClose = () => { - // clear form from reducer + const [isSaving, setIsSaving] = useState(false); + + const closeDialog = () => { resetSponsorCustomizedForm(); onClose(); }; + const handleClose = () => { + if (isSaving) return; + closeDialog(); + }; + const handleOnSave = (values) => { + if (isSaving) return; + const save = values.id ? updateSponsorCustomizedForm : saveSponsorCustomizedForm; + setIsSaving(true); - save(values).finally(() => { - handleClose(); - }); + save(values) + .then(() => { + if (onSaved) onSaved(); + closeDialog(); + }) + .catch(() => { + // keep dialog open on save error to preserve user input + }) + .finally(() => { + setIsSaving(false); + }); }; useEffect(() => { @@ -54,7 +72,13 @@ const CustomizedFormPopup = ({ }, [formId]); return ( - + {T.translate("edit_sponsor.forms_tab.customized_form.title")} - + @@ -72,6 +101,7 @@ const CustomizedFormPopup = ({ sponsor={sponsor} summitId={summitId} summitTZ={summitTZ} + isSaving={isSaving} onSubmit={handleOnSave} /> @@ -80,7 +110,8 @@ const CustomizedFormPopup = ({ CustomizedFormPopup.propTypes = { open: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired + onClose: PropTypes.func.isRequired, + onSaved: PropTypes.func }; const mapStateToProps = ({ diff --git a/src/pages/sponsors/sponsor-forms-tab/components/customized-form/customized-form.js b/src/pages/sponsors/sponsor-forms-tab/components/customized-form/customized-form.js index a94aad825..485579c17 100644 --- a/src/pages/sponsors/sponsor-forms-tab/components/customized-form/customized-form.js +++ b/src/pages/sponsors/sponsor-forms-tab/components/customized-form/customized-form.js @@ -47,6 +47,7 @@ const CustomizedForm = ({ sponsor, summitId, summitTZ, + isSaving = false, onSubmit }) => { const sponsorships = sponsor.sponsorships_collection.sponsorships.map( @@ -167,7 +168,12 @@ const CustomizedForm = ({ - diff --git a/src/pages/sponsors/sponsor-forms-tab/index.js b/src/pages/sponsors/sponsor-forms-tab/index.js index aa62f1b1d..082012323 100644 --- a/src/pages/sponsors/sponsor-forms-tab/index.js +++ b/src/pages/sponsors/sponsor-forms-tab/index.js @@ -172,6 +172,23 @@ const SponsorFormsTab = ({ }); }; + const handleCustomizedFormSaved = () => { + const { + perPage: customizedPerPage, + order: customizedOrder, + orderDir: customizedOrderDir + } = customizedForms; + + getSponsorCustomizedForms( + term, + DEFAULT_CURRENT_PAGE, + customizedPerPage, + customizedOrder, + customizedOrderDir, + hideArchived + ); + }; + const baseColumns = (name, manageItemsFn) => [ { columnKey: "name", @@ -372,19 +389,19 @@ const SponsorFormsTab = ({ /> - {openPopup === "template" && ( - setOpenPopup(null)} - onSubmit={handleSaveFormFromTemplate} - sponsor={sponsor} - summitId={summitId} - /> - )} + setOpenPopup(null)} + onSubmit={handleSaveFormFromTemplate} + sponsor={sponsor} + summitId={summitId} + /> setCustomFormEdit(null)} + onSaved={handleCustomizedFormSaved} sponsor={sponsor} summitId={summitId} />