diff --git a/src/i18n/en.json b/src/i18n/en.json
index 935ec2de4..231678578 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -4215,7 +4215,9 @@
"upload_deadline": "Upload Deadline",
"max_file_size": "Max File Size (MB)",
"allowed_formats": "Allowed Formats",
- "module_remove_warning": "Please verify you want to delete this {name}"
+ "module_remove_warning": "Please verify you want to delete this {name}",
+ "clone_module": "Clone",
+ "clone_count_label": "Number of copies to create"
},
"clone_success": "Page template cloned successfully."
},
diff --git a/src/pages/sponsors-global/page-templates/page-template-popup/__tests__/page-template-module-form.test.js b/src/pages/sponsors-global/page-templates/page-template-popup/__tests__/page-template-module-form.test.js
index 824d64c39..45c1065e3 100644
--- a/src/pages/sponsors-global/page-templates/page-template-popup/__tests__/page-template-module-form.test.js
+++ b/src/pages/sponsors-global/page-templates/page-template-popup/__tests__/page-template-module-form.test.js
@@ -1,5 +1,5 @@
import React from "react";
-import { render, screen, waitFor } from "@testing-library/react";
+import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Formik, Form, useFormikContext } from "formik";
import { Provider } from "react-redux";
@@ -11,7 +11,8 @@ import showConfirmDialog from "openstack-uicore-foundation/lib/components/mui/sh
import PageModules from "../page-template-modules-form";
import {
PAGES_MODULE_KINDS,
- PAGE_MODULES_MEDIA_TYPES
+ PAGE_MODULES_MEDIA_TYPES,
+ PAGE_MODULES_DOWNLOAD
} from "../../../../../utils/constants";
const mockStore = configureStore([thunk]);
@@ -108,6 +109,12 @@ const renderWithFormik = (
);
};
+// jsdom does not implement scrollIntoView; stub it so effects that call it
+// (auto-scroll to a new/cloned module) don't throw in these component tests.
+beforeAll(() => {
+ window.HTMLElement.prototype.scrollIntoView = jest.fn();
+});
+
describe("PageModules", () => {
const createModule = (kind, order, id) => ({
_tempId: `temp-${id}`,
@@ -617,4 +624,211 @@ describe("PageModules", () => {
});
});
});
+
+ describe("Cloning modules", () => {
+ const renderModulesWithWrapper = (modules) => {
+ const TestWrapper = () => {
+ const { values } = useFormikContext();
+ return (
+ <>
+
+
+ {values.modules.map((m) => m._tempId).join(",")}
+
+
+ {values.modules.map((m) => (m.id ? "1" : "0")).join(",")}
+
+ >
+ );
+ };
+
+ const store = mockStore({
+ mediaUploadState: { media_file_types: [] }
+ });
+ return render(
+
+
+
+
+
+ );
+ };
+
+ test("inserts N copies immediately after the original, each with a fresh temp id and no persisted id, and scrolls to the last one", async () => {
+ const modules = [
+ { ...createModule(PAGES_MODULE_KINDS.INFO, 0, 1), id: 100 },
+ createModule(PAGES_MODULE_KINDS.DOCUMENT, 1, 2),
+ createModule(PAGES_MODULE_KINDS.MEDIA, 2, 3)
+ ];
+ renderModulesWithWrapper(modules);
+
+ const countInput = screen.getAllByTestId("clone-count-input")[0];
+ fireEvent.change(countInput, { target: { value: "3" } });
+ await userEvent.click(screen.getAllByTestId("clone-module-btn")[0]);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("module-ids")).toHaveTextContent(
+ /^temp-1,temp-clone-\d+,temp-clone-\d+,temp-clone-\d+,temp-2,temp-3$/
+ );
+ });
+ expect(screen.getByTestId("module-has-id")).toHaveTextContent(
+ "1,0,0,0,0,0"
+ );
+ expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
+ });
+
+ test("clones a Media (type=File) module and propagates its type-specific fields", async () => {
+ const modules = [createModule(PAGES_MODULE_KINDS.MEDIA, 0, 1)];
+
+ const TestWrapper = () => {
+ const { values } = useFormikContext();
+ const clone = values.modules[1];
+ return (
+ <>
+
+
+ {JSON.stringify({
+ type: clone?.type,
+ max_file_size: clone?.max_file_size,
+ file_type_id: clone?.file_type_id
+ })}
+
+ >
+ );
+ };
+
+ const store = mockStore({ mediaUploadState: { media_file_types: [] } });
+ render(
+
+
+
+
+
+ );
+
+ await userEvent.click(screen.getByTestId("clone-module-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("clone-media-fields")).toHaveTextContent(
+ JSON.stringify({
+ type: PAGE_MODULES_MEDIA_TYPES.FILE,
+ max_file_size: 100,
+ file_type_id: 1
+ })
+ );
+ });
+ });
+
+ test.each([
+ ["0", 1],
+ ["999", 20]
+ ])("clamps a typed count of %s to %i", (typedValue, expected) => {
+ const modules = [createModule(PAGES_MODULE_KINDS.INFO, 0, 1)];
+ renderModulesWithWrapper(modules);
+
+ const countInput = screen.getByTestId("clone-count-input");
+ fireEvent.change(countInput, { target: { value: typedValue } });
+
+ expect(countInput).toHaveValue(expected);
+ });
+
+ test("collapses new clones, keeps the original expanded, and resets the count field to 1", async () => {
+ const modules = [createModule(PAGES_MODULE_KINDS.INFO, 0, 1)];
+ renderModulesWithWrapper(modules);
+
+ const countInput = screen.getByTestId("clone-count-input");
+ fireEvent.change(countInput, { target: { value: "2" } });
+ await userEvent.click(screen.getByTestId("clone-module-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("module-ids")).toHaveTextContent(
+ /^temp-1,temp-clone-\d+,temp-clone-\d+$/
+ );
+ });
+
+ expect(
+ screen.getByTestId("text-editor-modules[0].content")
+ ).toBeVisible();
+ expect(
+ screen.getByTestId("text-editor-modules[1].content")
+ ).not.toBeVisible();
+ expect(
+ screen.getByTestId("text-editor-modules[2].content")
+ ).not.toBeVisible();
+ expect(countInput).toHaveValue(1);
+ });
+
+ describe("Document Download file handling", () => {
+ const createDocumentModule = (file) => ({
+ _tempId: "temp-doc-1",
+ kind: PAGES_MODULE_KINDS.DOCUMENT,
+ custom_order: 0,
+ name: "Doc",
+ description: "Desc",
+ type: PAGE_MODULES_DOWNLOAD.FILE,
+ external_url: "",
+ file
+ });
+
+ test.each([
+ [
+ "resets an already-uploaded file to empty on each clone",
+ [{ id: 10, file_url: "http://x/file.pdf" }],
+ "[]"
+ ],
+ [
+ "copies a newly selected, not-yet-uploaded file as-is to every clone",
+ [{ name: "new-upload.pdf" }],
+ JSON.stringify([{ name: "new-upload.pdf" }])
+ ]
+ ])("%s", async (_description, sourceFile, expectedFileJson) => {
+ const modules = [createDocumentModule(sourceFile)];
+
+ const TestWrapper = () => {
+ const { values } = useFormikContext();
+ return (
+ <>
+
+
+ {JSON.stringify(values.modules[1]?.file)}
+
+
+ {JSON.stringify(values.modules[2]?.file)}
+
+ >
+ );
+ };
+
+ const store = mockStore({
+ mediaUploadState: { media_file_types: [] }
+ });
+ render(
+
+
+
+
+
+ );
+
+ const countInput = screen.getByTestId("clone-count-input");
+ fireEvent.change(countInput, { target: { value: "2" } });
+ await userEvent.click(screen.getByTestId("clone-module-btn"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("clone-file-1")).toHaveTextContent(
+ expectedFileJson
+ );
+ expect(screen.getByTestId("clone-file-2")).toHaveTextContent(
+ expectedFileJson
+ );
+ });
+ });
+ });
+ });
});
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
index 54c5576a4..d136b96ab 100644
--- 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
@@ -1,5 +1,5 @@
import React from "react";
-import { render, screen, waitFor } from "@testing-library/react";
+import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider } from "react-redux";
import configureStore from "redux-mock-store";
@@ -60,6 +60,12 @@ jest.mock(
}
);
+// jsdom does not implement scrollIntoView; stub it so effects that call it
+// (auto-scroll to a new/cloned module) don't throw in these component tests.
+beforeAll(() => {
+ window.HTMLElement.prototype.scrollIntoView = jest.fn();
+});
+
const baseMediaModule = {
_tempId: "temp-1",
kind: PAGES_MODULE_KINDS.MEDIA,
@@ -164,6 +170,60 @@ describe("PageTemplatePopup validation — empty-string normalization", () => {
});
});
+describe("PageTemplatePopup — cloning modules", () => {
+ it("sends cloned modules to save in order, with recomputed custom_order, alongside originals", async () => {
+ const onSave = jest.fn(() => Promise.resolve());
+ const modules = [
+ {
+ _tempId: "temp-1",
+ id: 11,
+ kind: PAGES_MODULE_KINDS.MEDIA,
+ type: PAGE_MODULES_MEDIA_TYPES.INPUT,
+ custom_order: 0,
+ name: "First module",
+ description: "First description",
+ upload_deadline: null
+ },
+ {
+ _tempId: "temp-2",
+ id: 12,
+ kind: PAGES_MODULE_KINDS.MEDIA,
+ type: PAGE_MODULES_MEDIA_TYPES.INPUT,
+ custom_order: 1,
+ name: "Second module",
+ description: "Second description",
+ upload_deadline: null
+ }
+ ];
+ renderPopup({ isGlobal: true, onSave, modules });
+
+ const countInput = screen.getAllByTestId("clone-count-input")[0];
+ fireEvent.change(countInput, { target: { value: "2" } });
+ await userEvent.click(screen.getAllByTestId("clone-module-btn")[0]);
+
+ await userEvent.click(
+ screen.getByRole("button", { name: "page_template_list.page_crud.save" })
+ );
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalled();
+ });
+
+ const savedModules = onSave.mock.calls[0][0].modules;
+ expect(savedModules.map((m) => m.name)).toEqual([
+ "First module",
+ "First module",
+ "First module",
+ "Second module"
+ ]);
+ expect(savedModules.map((m) => m.custom_order)).toEqual([0, 1, 2, 3]);
+ expect(savedModules[0].id).toBe(11);
+ expect(savedModules[1].id).toBeUndefined();
+ expect(savedModules[2].id).toBeUndefined();
+ expect(savedModules[3].id).toBe(12);
+ });
+});
+
describe("PageTemplatePopup — isSaving guard", () => {
const renderSavingPopup = ({ onClose, onSave }) => {
const store = mockStore({ mediaUploadState: { media_file_types: [] } });
diff --git a/src/pages/sponsors-global/page-templates/page-template-popup/module-clone-control.js b/src/pages/sponsors-global/page-templates/page-template-popup/module-clone-control.js
new file mode 100644
index 000000000..eb033c2e2
--- /dev/null
+++ b/src/pages/sponsors-global/page-templates/page-template-popup/module-clone-control.js
@@ -0,0 +1,74 @@
+import React, { useState } from "react";
+import PropTypes from "prop-types";
+import T from "i18n-react/dist/i18n-react";
+import { Box, Button, InputAdornment, TextField } from "@mui/material";
+import {
+ MAX_MODULE_CLONE_COUNT,
+ MIN_MODULE_CLONE_COUNT
+} from "../../../../utils/constants";
+
+const clampCloneCount = (value) => {
+ if (Number.isNaN(value)) return MIN_MODULE_CLONE_COUNT;
+ return Math.min(
+ Math.max(value, MIN_MODULE_CLONE_COUNT),
+ MAX_MODULE_CLONE_COUNT
+ );
+};
+
+const ModuleCloneControl = ({ onClone }) => {
+ const [count, setCount] = useState(MIN_MODULE_CLONE_COUNT);
+
+ const handleCountChange = (e) => {
+ setCount(clampCloneCount(parseInt(e.target.value, 10)));
+ };
+
+ const handleClone = () => {
+ onClone(count);
+ setCount(MIN_MODULE_CLONE_COUNT);
+ };
+
+ return (
+ e.stopPropagation()}
+ >
+ x
+ },
+ htmlInput: {
+ min: MIN_MODULE_CLONE_COUNT,
+ max: MAX_MODULE_CLONE_COUNT,
+ "aria-label": T.translate(
+ "page_template_list.page_crud.clone_count_label"
+ ),
+ "data-testid": "clone-count-input"
+ }
+ }}
+ />
+
+
+
+
+ );
+};
+
+ModuleCloneControl.propTypes = {
+ onClone: PropTypes.func.isRequired
+};
+
+export default ModuleCloneControl;
diff --git a/src/pages/sponsors-global/page-templates/page-template-popup/page-template-modules-form.js b/src/pages/sponsors-global/page-templates/page-template-popup/page-template-modules-form.js
index 0d7ab6d69..767caac91 100644
--- a/src/pages/sponsors-global/page-templates/page-template-popup/page-template-modules-form.js
+++ b/src/pages/sponsors-global/page-templates/page-template-popup/page-template-modules-form.js
@@ -18,11 +18,13 @@ import DragAndDropList from "openstack-uicore-foundation/lib/components/mui/dnd-
import showConfirmDialog from "openstack-uicore-foundation/lib/components/mui/show-confirm-dialog";
import {
DEBOUNCE_WAIT_150,
- PAGES_MODULE_KINDS
+ PAGES_MODULE_KINDS,
+ PAGE_MODULES_DOWNLOAD
} from "../../../../utils/constants";
import InfoModule from "./modules/page-template-info-module";
import DocumentDownloadModule from "./modules/page-template-document-download-module";
import MediaRequestModule from "./modules/page-template-media-request-module";
+import ModuleCloneControl from "./module-clone-control";
import { getAllMediaFileTypes } from "../../../../actions/media-file-type-actions";
const PageModules = ({
@@ -37,15 +39,25 @@ const PageModules = ({
const bottomRef = useRef(null);
const prevModulesLength = useRef(modules.length);
const moduleRefMap = useRef(new Map());
+ const cloneIdCounter = useRef(0);
+ const cloneScrollTargetRef = useRef(null);
const [collapsedModules, setCollapsedModules] = useState(new Set());
const getModuleId = (module) => module._tempId || module.id;
- // auto-scroll to new module
+ // auto-scroll to new module (or to the last cloned copy, when cloning)
useEffect(() => {
if (modules.length > prevModulesLength.current) {
- bottomRef.current?.scrollIntoView({ behavior: "smooth" });
+ if (cloneScrollTargetRef.current) {
+ const targetId = cloneScrollTargetRef.current;
+ cloneScrollTargetRef.current = null;
+ moduleRefMap.current
+ .get(targetId)
+ ?.scrollIntoView({ behavior: "smooth" });
+ } else {
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
+ }
}
prevModulesLength.current = modules.length;
}, [modules.length]);
@@ -110,6 +122,51 @@ const PageModules = ({
}
};
+ // A Document Download module's file field must never be silently carried into a
+ // clone: an already-uploaded file (has id/file_id) would look attached in the UI
+ // but get stripped at save time by normalizePageTemplateModules, while a newly
+ // selected, not-yet-uploaded file is copied as-is.
+ const buildClonedDocumentFile = (module) => {
+ const file = Array.isArray(module.file) ? module.file[0] : null;
+ const isNewFile =
+ file && typeof file === "object" && !file.id && !file.file_id;
+ return isNewFile ? module.file : [];
+ };
+
+ const handleCloneModule = (index, module, count) => {
+ const isDocumentFile =
+ module.kind === PAGES_MODULE_KINDS.DOCUMENT &&
+ module.type === PAGE_MODULES_DOWNLOAD.FILE;
+ const clonedFile = isDocumentFile ? buildClonedDocumentFile(module) : null;
+
+ const sourceData = { ...module };
+ delete sourceData.id;
+
+ const clones = Array.from({ length: count }, () => {
+ cloneIdCounter.current += 1;
+ return {
+ ...sourceData,
+ ...(isDocumentFile ? { file: clonedFile } : {}),
+ _tempId: `temp-clone-${cloneIdCounter.current}`
+ };
+ });
+
+ const updated = [
+ ...modules.slice(0, index + 1),
+ ...clones,
+ ...modules.slice(index + 1)
+ ];
+
+ setCollapsedModules((prev) => {
+ const next = new Set(prev);
+ clones.forEach((clone) => next.add(clone._tempId));
+ return next;
+ });
+
+ cloneScrollTargetRef.current = clones[clones.length - 1]._tempId;
+ setFieldValue(name, updated);
+ };
+
const handleReorderModules = (newModules) => {
setFieldValue(name, newModules);
};
@@ -188,6 +245,9 @@ const PageModules = ({
sx={{ display: "flex", alignItems: "center" }}
onClick={(e) => e.stopPropagation()}
>
+ handleCloneModule(index, module, count)}
+ />
diff --git a/src/utils/constants.js b/src/utils/constants.js
index 846f53c8e..929f0f8de 100644
--- a/src/utils/constants.js
+++ b/src/utils/constants.js
@@ -284,6 +284,9 @@ export const PAGE_MODULES_DOWNLOAD = {
URL: "Url"
};
+export const MIN_MODULE_CLONE_COUNT = 1;
+export const MAX_MODULE_CLONE_COUNT = 20;
+
export const PURCHASE_STATUS = {
PENDING: "Pending",
PAID: "Paid",