diff --git a/src/actions/page-template-actions.js b/src/actions/page-template-actions.js
index 3f1d455d0..f719b79a2 100644
--- a/src/actions/page-template-actions.js
+++ b/src/actions/page-template-actions.js
@@ -175,10 +175,9 @@ export const savePageTemplate = (entity) => async (dispatch) => {
html: T.translate("page_template_list.page_crud.page_saved")
})
);
- getPageTemplates()(dispatch);
})
.catch((err) => {
- console.error(err);
+ throw err;
})
.finally(() => {
dispatch(stopLoading());
@@ -200,10 +199,9 @@ export const savePageTemplate = (entity) => async (dispatch) => {
html: T.translate("page_template_list.page_crud.page_created")
})
);
- getPageTemplates()(dispatch);
})
.catch((err) => {
- console.error(err);
+ throw err;
})
.finally(() => {
dispatch(stopLoading());
diff --git a/src/pages/sponsors-global/page-templates/page-template-list-page.js b/src/pages/sponsors-global/page-templates/page-template-list-page.js
index 0b4b05d21..b19a3e416 100644
--- a/src/pages/sponsors-global/page-templates/page-template-list-page.js
+++ b/src/pages/sponsors-global/page-templates/page-template-list-page.js
@@ -114,9 +114,17 @@ const PageTemplateListPage = ({
setOpenCloneDialog(true);
};
- const handleSavePageTemplate = (entity) => {
- savePageTemplate(entity).then(() => setOpenPageDialog(false));
- };
+ const handleSavePageTemplate = (entity) =>
+ savePageTemplate(entity).then(() => {
+ getPageTemplates(
+ term,
+ DEFAULT_CURRENT_PAGE,
+ perPage,
+ order,
+ orderDir,
+ showArchived
+ ).catch(() => {});
+ });
const handleArchive = (item) =>
item.is_archived
@@ -285,6 +293,7 @@ const PageTemplateListPage = ({
pageTemplate={pageTemplate}
onClose={handleClosePageDialog}
onSave={handleSavePageTemplate}
+ isGlobal
/>
)}
{openCloneDialog && (
diff --git a/src/pages/sponsors-global/page-templates/page-template-popup/page-template-module-form.test.js b/src/pages/sponsors-global/page-templates/page-template-popup/__tests__/page-template-module-form.test.js
similarity index 90%
rename from src/pages/sponsors-global/page-templates/page-template-popup/page-template-module-form.test.js
rename to src/pages/sponsors-global/page-templates/page-template-popup/__tests__/page-template-module-form.test.js
index ab792f581..8a8604bcc 100644
--- a/src/pages/sponsors-global/page-templates/page-template-popup/page-template-module-form.test.js
+++ b/src/pages/sponsors-global/page-templates/page-template-popup/__tests__/page-template-module-form.test.js
@@ -6,23 +6,26 @@ import { Provider } from "react-redux";
import configureStore from "redux-mock-store";
import thunk from "redux-thunk";
import "@testing-library/jest-dom";
-import PageModules from "./page-template-modules-form";
-import showConfirmDialog from "../../../../components/mui/showConfirmDialog";
+import showConfirmDialog from "openstack-uicore-foundation/lib/components/mui/show-confirm-dialog";
+import PageModules from "../page-template-modules-form";
import {
PAGES_MODULE_KINDS,
PAGE_MODULES_MEDIA_TYPES
-} from "../../../../utils/constants";
+} from "../../../../../utils/constants";
const mockStore = configureStore([thunk]);
// Mocks
-jest.mock("../../../../components/mui/showConfirmDialog", () => jest.fn());
-jest.mock("../../../../actions/media-file-type-actions", () => ({
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/show-confirm-dialog",
+ () => jest.fn()
+);
+jest.mock("../../../../../actions/media-file-type-actions", () => ({
getAllMediaFileTypes: jest.fn(() => () => Promise.resolve())
}));
jest.mock(
- "../../../../components/inputs/formik-text-editor",
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/texteditor",
() =>
function MockFormikTextEditor({ name }) {
return ;
@@ -38,7 +41,7 @@ jest.mock(
);
jest.mock(
- "../../../../components/mui/formik-inputs/mui-formik-textfield",
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield",
() =>
function MockMuiFormikTextField({ name }) {
return ;
@@ -46,7 +49,7 @@ jest.mock(
);
jest.mock(
- "../../../../components/mui/formik-inputs/mui-formik-select",
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/select",
() =>
function MockMuiFormikSelect({ name, children }) {
return ;
@@ -62,25 +65,17 @@ jest.mock(
);
jest.mock(
- "../../../../components/mui/formik-inputs/mui-formik-radio-group",
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/radio-group",
() =>
function MockMuiFormikRadioGroup({ name }) {
return
;
}
);
-jest.mock(
- "../../../../components/mui/formik-inputs/mui-formik-async-select",
- () =>
- function MockMuiFormikAsyncSelect({ name }) {
- return ;
- }
-);
-
// Mock DragAndDropList to capture onReorder
let capturedOnReorder = null;
jest.mock(
- "../../../../components/mui/dnd-list",
+ "openstack-uicore-foundation/lib/components/mui/dnd-list",
() =>
function MockDragAndDropList({ items, renderItem, onReorder }) {
capturedOnReorder = onReorder;
@@ -97,7 +92,10 @@ jest.mock(
);
// Helper function to render the component with Formik and Redux
-const renderWithFormik = (initialValues = { modules: [] }) => {
+const renderWithFormik = (
+ initialValues = { modules: [] },
+ isGlobal = false
+) => {
const store = mockStore({
mediaUploadState: {
media_file_types: []
@@ -107,7 +105,7 @@ const renderWithFormik = (initialValues = { modules: [] }) => {
@@ -173,6 +171,26 @@ describe("PageModules", () => {
});
});
+ describe("isGlobal", () => {
+ test("shows the upload deadline datepicker for a MEDIA module when isGlobal is false", () => {
+ const modules = [createModule(PAGES_MODULE_KINDS.MEDIA, 0, 1)];
+ renderWithFormik({ modules }, false);
+
+ expect(
+ screen.getByTestId("datepicker-modules[0].upload_deadline")
+ ).toBeInTheDocument();
+ });
+
+ test("hides the upload deadline datepicker for a MEDIA module when isGlobal is true", () => {
+ const modules = [createModule(PAGES_MODULE_KINDS.MEDIA, 0, 1)];
+ renderWithFormik({ modules }, true);
+
+ expect(
+ screen.queryByTestId("datepicker-modules[0].upload_deadline")
+ ).not.toBeInTheDocument();
+ });
+ });
+
describe("Module ordering", () => {
test("renders modules in the order they appear in the array", () => {
const modules = [
diff --git a/src/pages/sponsors-global/page-templates/page-template-popup/__tests__/page-template-popup.test.js b/src/pages/sponsors-global/page-templates/page-template-popup/__tests__/page-template-popup.test.js
new file mode 100644
index 000000000..dd62bb070
--- /dev/null
+++ b/src/pages/sponsors-global/page-templates/page-template-popup/__tests__/page-template-popup.test.js
@@ -0,0 +1,247 @@
+import React from "react";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Provider } from "react-redux";
+import configureStore from "redux-mock-store";
+import thunk from "redux-thunk";
+import { yupToFormErrors } from "formik";
+import "@testing-library/jest-dom";
+import PageTemplatePopup from "../index";
+import {
+ PAGES_MODULE_KINDS,
+ PAGE_MODULES_MEDIA_TYPES
+} from "../../../../../utils/constants";
+
+const mockStore = configureStore([thunk]);
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+jest.mock("formik", () => {
+ const actual = jest.requireActual("formik");
+ return {
+ __esModule: true,
+ ...actual,
+ yupToFormErrors: jest.fn(actual.yupToFormErrors)
+ };
+});
+
+jest.mock("../../../../../actions/media-file-type-actions", () => ({
+ getAllMediaFileTypes: jest.fn(() => () => Promise.resolve())
+}));
+
+jest.mock("../../../../../actions/sponsor-actions", () => ({
+ querySponsorAddons: jest.fn()
+}));
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/datepicker",
+ () =>
+ function MockMuiFormikDatepicker({ name }) {
+ return ;
+ }
+);
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield",
+ () =>
+ function MockMuiFormikTextField({ name }) {
+ return ;
+ }
+);
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/select-group",
+ () =>
+ function MockMuiFormikRadioGroup({ name }) {
+ return ;
+ }
+);
+
+const baseMediaModule = {
+ _tempId: "temp-1",
+ kind: PAGES_MODULE_KINDS.MEDIA,
+ type: PAGE_MODULES_MEDIA_TYPES.INPUT,
+ name: "Resume",
+ description: "Upload your resume",
+ upload_deadline: null
+};
+
+const renderPopup = ({
+ isGlobal,
+ onSave,
+ modules = [baseMediaModule]
+} = {}) => {
+ const store = mockStore({ mediaUploadState: { media_file_types: [] } });
+ return render(
+
+
+
+ );
+};
+
+describe("PageTemplatePopup — upload_deadline requiredness", () => {
+ it("blocks submit when upload_deadline is missing and isGlobal is false", async () => {
+ const onSave = jest.fn();
+ renderPopup({ isGlobal: false, onSave });
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "page_template_list.page_crud.save" })
+ );
+
+ await waitFor(() => {
+ expect(onSave).not.toHaveBeenCalled();
+ });
+ });
+
+ it("allows submit when upload_deadline is missing and isGlobal is true", async () => {
+ const onSave = jest.fn(() => Promise.resolve());
+ renderPopup({ isGlobal: true, onSave });
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "page_template_list.page_crud.save" })
+ );
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalled();
+ });
+ });
+
+ it("clears a stale upload_deadline carried over from a legacy module when isGlobal is true", async () => {
+ const onSave = jest.fn(() => Promise.resolve());
+ const legacyModule = {
+ ...baseMediaModule,
+ upload_deadline: 1700000000
+ };
+ renderPopup({ isGlobal: true, onSave, modules: [legacyModule] });
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "page_template_list.page_crud.save" })
+ );
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalled();
+ });
+ expect(onSave.mock.calls[0][0].modules[0].upload_deadline).toBeNull();
+ });
+});
+
+describe("PageTemplatePopup validation — empty-string normalization", () => {
+ it("reports the standard required message (not a Yup cast error) when max_file_size is cleared to an empty string", async () => {
+ const module = {
+ _tempId: "temp-1",
+ kind: PAGES_MODULE_KINDS.MEDIA,
+ type: PAGE_MODULES_MEDIA_TYPES.FILE,
+ name: "Doc",
+ description: "Desc",
+ upload_deadline: 1700000000,
+ max_file_size: "",
+ file_type_id: 1
+ };
+ renderPopup({ isGlobal: false, onSave: jest.fn(), modules: [module] });
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "page_template_list.page_crud.save" })
+ );
+
+ await waitFor(() => {
+ expect(yupToFormErrors).toHaveBeenCalled();
+ });
+ const errors = yupToFormErrors.mock.results.at(-1).value;
+ expect(errors.modules[0].max_file_size).toBe("validation.required");
+ });
+});
+
+describe("PageTemplatePopup — isSaving guard", () => {
+ const renderSavingPopup = ({ onClose, onSave }) => {
+ const store = mockStore({ mediaUploadState: { media_file_types: [] } });
+ return render(
+
+
+
+ );
+ };
+
+ it("disables the submit and close buttons while a save is in flight, and closes on success", async () => {
+ let resolveSave;
+ const onSave = jest.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveSave = resolve;
+ })
+ );
+ const onClose = jest.fn();
+ renderSavingPopup({ onClose, onSave });
+
+ const saveButton = screen.getByRole("button", {
+ name: "page_template_list.page_crud.save"
+ });
+ await userEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(saveButton).toBeDisabled();
+ });
+ expect(screen.getByTestId("CloseIcon").closest("button")).toBeDisabled();
+
+ resolveSave();
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it("ignores a second click while saving (no double submit)", async () => {
+ let resolveSave;
+ const onSave = jest.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveSave = resolve;
+ })
+ );
+ renderSavingPopup({ onClose: jest.fn(), onSave });
+
+ const saveButton = screen.getByRole("button", {
+ name: "page_template_list.page_crud.save"
+ });
+ await userEvent.click(saveButton);
+ await waitFor(() => {
+ expect(saveButton).toBeDisabled();
+ });
+
+ resolveSave();
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("keeps the dialog open and re-enables the form when onSave rejects", async () => {
+ const onSave = jest.fn(() => Promise.reject(new Error("save failed")));
+ const onClose = jest.fn();
+ renderSavingPopup({ onClose, onSave });
+
+ const saveButton = screen.getByRole("button", {
+ name: "page_template_list.page_crud.save"
+ });
+ await userEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalled();
+ });
+ await waitFor(() => {
+ expect(saveButton).not.toBeDisabled();
+ });
+ expect(onClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/pages/sponsors-global/page-templates/page-template-popup/index.js b/src/pages/sponsors-global/page-templates/page-template-popup/index.js
index 91a7b2068..cb19f223a 100644
--- a/src/pages/sponsors-global/page-templates/page-template-popup/index.js
+++ b/src/pages/sponsors-global/page-templates/page-template-popup/index.js
@@ -1,4 +1,4 @@
-import React from "react";
+import React, { useState } from "react";
import T from "i18n-react/dist/i18n-react";
import PropTypes from "prop-types";
import {
@@ -17,10 +17,16 @@ import {
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import CloseIcon from "@mui/icons-material/Close";
-import { FormikProvider, useFormik } from "formik";
+import {
+ FormikProvider,
+ prepareDataForValidation,
+ useFormik,
+ yupToFormErrors
+} from "formik";
import * as yup from "yup";
import MuiDropdownCheckbox from "openstack-uicore-foundation/lib/components/mui/dropdown-checkbox";
-import MuiFormikTextField from "../../../../components/mui/formik-inputs/mui-formik-textfield";
+import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield";
+import MuiFormikSelectGroup from "openstack-uicore-foundation/lib/components/mui/formik-inputs/select-group";
import PageModules from "./page-template-modules-form";
import {
BYTES_PER_MB,
@@ -30,9 +36,76 @@ import {
PAGE_MODULES_MEDIA_TYPES,
PAGE_MODULES_DOWNLOAD
} from "../../../../utils/constants";
-import MuiFormikSelectGroup from "../../../../components/mui/formik-inputs/mui-formik-select-group";
import { querySponsorAddons } from "../../../../actions/sponsor-actions";
+const infoModuleSchema = yup.object().shape({
+ kind: yup.string().equals([PAGES_MODULE_KINDS.INFO]),
+ content: yup.string().required(T.translate("validation.required"))
+});
+
+const documentModuleSchema = yup.object().shape({
+ kind: yup.string().equals([PAGES_MODULE_KINDS.DOCUMENT]),
+ name: yup.string().required(T.translate("validation.required")),
+ description: yup.string().required(T.translate("validation.required")),
+ external_url: yup.string().when("type", {
+ is: PAGE_MODULES_DOWNLOAD.URL,
+ then: (schema) => schema.required(T.translate("validation.required")),
+ otherwise: (schema) => schema.nullable()
+ }),
+ file: yup.array().when("type", {
+ is: PAGE_MODULES_DOWNLOAD.FILE,
+ then: (schema) =>
+ schema
+ .nullable()
+ .required(T.translate("validation.file_required"))
+ .min(1, T.translate("validation.file_required")),
+ otherwise: (schema) => schema.nullable()
+ })
+});
+
+const mediaModuleSchema = yup.object().shape({
+ kind: yup.string().equals([PAGES_MODULE_KINDS.MEDIA]),
+ name: yup.string().required(T.translate("validation.required")),
+ type: yup.string().required(T.translate("validation.required")),
+ upload_deadline: yup.date().when("$isGlobal", {
+ is: true,
+ then: (s) => s.nullable(),
+ otherwise: (s) => s.required(T.translate("validation.required"))
+ }),
+ description: yup.string().required(T.translate("validation.required")),
+ max_file_size: yup.number().when("type", {
+ is: PAGE_MODULES_MEDIA_TYPES.FILE,
+ then: (schema) =>
+ schema
+ .min(BYTES_PER_MB, T.translate("validation.non_negative"))
+ .required(T.translate("validation.required"))
+ .test(
+ "mib-aligned",
+ T.translate("validation.mib_aligned"),
+ (value) => value == null || value % BYTES_PER_MB === 0
+ ),
+ otherwise: (schema) => schema.nullable()
+ }),
+ file_type_id: yup.number().when("type", {
+ is: PAGE_MODULES_MEDIA_TYPES.FILE,
+ then: (schema) => schema.required(T.translate("validation.required")),
+ otherwise: (schema) => schema.nullable()
+ })
+});
+
+const moduleSchema = yup.lazy((value) => {
+ switch (value?.kind) {
+ case PAGES_MODULE_KINDS.INFO:
+ return infoModuleSchema;
+ case PAGES_MODULE_KINDS.DOCUMENT:
+ return documentModuleSchema;
+ case PAGES_MODULE_KINDS.MEDIA:
+ return mediaModuleSchema;
+ default:
+ return yup.object();
+ }
+});
+
const PageTemplatePopup = ({
pageTemplate,
onClose,
@@ -41,8 +114,10 @@ const PageTemplatePopup = ({
summitId,
sponsorId,
sponsorshipIds,
- title
+ title,
+ isGlobal
}) => {
+ const [isSaving, setIsSaving] = useState(false);
const popupTitle =
title ??
(pageTemplate?.id
@@ -51,92 +126,59 @@ const PageTemplatePopup = ({
const showSponsorships =
Array.isArray(sponsorships) && sponsorships.length > 0;
- const showAllowedAddons = summitId && sponsorId && sponsorshipIds?.length > 0;
-
- const infoModuleSchema = yup.object().shape({
- kind: yup.string().equals([PAGES_MODULE_KINDS.INFO]),
- content: yup.string().required(T.translate("validation.required"))
- });
-
- const documentModuleSchema = yup.object().shape({
- kind: yup.string().equals([PAGES_MODULE_KINDS.DOCUMENT]),
- name: yup.string().required(T.translate("validation.required")),
- description: yup.string().required(T.translate("validation.required")),
- external_url: yup.string().when("type", {
- is: PAGE_MODULES_DOWNLOAD.URL,
- then: (schema) => schema.required(T.translate("validation.required")),
- otherwise: (schema) => schema.nullable()
- }),
- file: yup.array().when("type", {
- is: PAGE_MODULES_DOWNLOAD.FILE,
- then: (schema) =>
- schema
- .nullable()
- .required(T.translate("validation.file_required"))
- .min(1, T.translate("validation.file_required")),
- otherwise: (schema) => schema.nullable()
- })
- });
+ const handleClose = () => {
+ if (isSaving) return;
+ onClose();
+ };
- const mediaModuleSchema = yup.object().shape({
- kind: yup.string().equals([PAGES_MODULE_KINDS.MEDIA]),
- name: yup.string().required(T.translate("validation.required")),
- type: yup.string().required(T.translate("validation.required")),
- upload_deadline: yup.date().required(T.translate("validation.required")),
- description: yup.string().required(T.translate("validation.required")),
- max_file_size: yup.number().when("type", {
- is: PAGE_MODULES_MEDIA_TYPES.FILE,
- then: (schema) =>
- schema
- .min(BYTES_PER_MB, T.translate("validation.non_negative"))
- .required(T.translate("validation.required"))
- .test(
- "mib-aligned",
- T.translate("validation.mib_aligned"),
- (value) => value == null || value % BYTES_PER_MB === 0
- ),
- otherwise: (schema) => schema.nullable()
- }),
- file_type_id: yup.number().when("type", {
- is: PAGE_MODULES_MEDIA_TYPES.FILE,
- then: (schema) => schema.required(T.translate("validation.required")),
- otherwise: (schema) => schema.nullable()
- })
- });
+ const showAllowedAddons = summitId && sponsorId && sponsorshipIds?.length > 0;
- const moduleSchema = yup.lazy((value) => {
- switch (value?.kind) {
- case PAGES_MODULE_KINDS.INFO:
- return infoModuleSchema;
- case PAGES_MODULE_KINDS.DOCUMENT:
- return documentModuleSchema;
- case PAGES_MODULE_KINDS.MEDIA:
- return mediaModuleSchema;
- default:
- return yup.object();
- }
- });
+ const validationSchema = React.useMemo(
+ () =>
+ yup.object().shape({
+ code: yup.string().required(T.translate("validation.required")),
+ name: yup.string().required(T.translate("validation.required")),
+ ...(showSponsorships && {
+ sponsorship_types: yup
+ .array()
+ .min(1, T.translate("validation.required"))
+ }),
+ modules: yup.array().of(moduleSchema)
+ }),
+ [showSponsorships]
+ );
const formik = useFormik({
initialValues: pageTemplate,
- validationSchema: yup.object().shape({
- code: yup.string().required(T.translate("validation.required")),
- name: yup.string().required(T.translate("validation.required")),
- ...(showSponsorships && {
- sponsorship_types: yup
- .array()
- .min(1, T.translate("validation.required"))
- }),
- modules: yup.array().of(moduleSchema)
- }),
+ validate: (values) => {
+ try {
+ validationSchema.validateSync(prepareDataForValidation(values), {
+ abortEarly: false,
+ context: { isGlobal }
+ });
+ return {};
+ } catch (err) {
+ return yupToFormErrors(err);
+ }
+ },
enableReinitialize: true,
onSubmit: (values) => {
+ if (isSaving) return;
+ setIsSaving(true);
+
const modulesWithOrder = values.modules.map((m, idx) => ({
...m,
custom_order: idx
}));
- onSave({ ...values, modules: modulesWithOrder });
+ onSave({ ...values, modules: modulesWithOrder })
+ .then(() => {
+ onClose();
+ })
+ .catch(() => {})
+ .finally(() => {
+ setIsSaving(false);
+ });
}
});
@@ -180,10 +222,21 @@ const PageTemplatePopup = ({
};
return (
-