diff --git a/src/i18n/en.json b/src/i18n/en.json
index 935ec2de4..d10ed499f 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -4215,7 +4215,10 @@
"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_disabled_persisted_file": "This document's file has already been uploaded and can't be copied. Clone is disabled to prevent creating copies without a file, which cannot be saved."
},
"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..a784be653 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]);
@@ -65,13 +66,15 @@ jest.mock(
}
);
-// Mock DragAndDropList to capture onReorder
+// Mock DragAndDropList to capture onReorder and the items it receives
let capturedOnReorder = null;
+let capturedItems = null;
jest.mock(
"openstack-uicore-foundation/lib/components/mui/dnd-list",
() =>
function MockDragAndDropList({ items, renderItem, onReorder }) {
capturedOnReorder = onReorder;
+ capturedItems = items;
return (
{items.map((item, index) => (
@@ -108,6 +111,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}`,
@@ -131,6 +140,7 @@ describe("PageModules", () => {
beforeEach(() => {
jest.clearAllMocks();
capturedOnReorder = null;
+ capturedItems = null;
});
describe("Rendering", () => {
@@ -617,4 +627,432 @@ 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("keeps a stable DnD key (derived from id) for a persisted module lacking _tempId, even after a clone shifts its array index", async () => {
+ // a module loaded from the API with no _tempId, as denormalizePageModules
+ // produces it — DragAndDropList's own idKey fallback is index-based, so
+ // without normalization this module's key would change (forcing a
+ // remount) whenever an earlier clone shifts its position.
+ const persistedNoTempId = {
+ id: 200,
+ kind: PAGES_MODULE_KINDS.INFO,
+ custom_order: 1,
+ name: "Persisted module",
+ content: ""
+ };
+ const modules = [
+ createModule(PAGES_MODULE_KINDS.INFO, 0, 1),
+ persistedNoTempId
+ ];
+ renderModulesWithWrapper(modules);
+
+ expect(capturedItems[1]._tempId).toBe(200);
+
+ const countInput = screen.getAllByTestId("clone-count-input")[0];
+ fireEvent.change(countInput, { target: { value: "2" } });
+ 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+,$/
+ );
+ });
+
+ // now at index 3 instead of 1, but the DnD key tracks the module, not the slot
+ const shiftedIndex = capturedItems.findIndex((m) => m.id === 200);
+ expect(shiftedIndex).toBe(3);
+ expect(capturedItems[shiftedIndex]._tempId).toBe(200);
+ });
+
+ // Cloning has no MEDIA-type-specific branching (unlike DOCUMENT, see below) —
+ // both variants exercise the same generic-copy code path, so a single
+ // parameterized test documents that max_file_size/file_type_id are carried
+ // over as-is regardless of type (they're only stripped for INPUT later, by
+ // normalizePageTemplateModules at save time, not by cloning).
+ test.each([
+ ["File", PAGE_MODULES_MEDIA_TYPES.FILE],
+ ["Input", PAGE_MODULES_MEDIA_TYPES.INPUT]
+ ])(
+ "clones a Media (type=%s) module and propagates its fields as-is",
+ async (_description, type) => {
+ const modules = [
+ { ...createModule(PAGES_MODULE_KINDS.MEDIA, 0, 1), type }
+ ];
+
+ 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, max_file_size: 100, file_type_id: 1 })
+ );
+ });
+ }
+ );
+
+ test.each([
+ ["0", 1],
+ ["999", 20]
+ ])("clamps a typed count of %s to %i on blur", (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 } });
+ fireEvent.blur(countInput);
+
+ expect(countInput).toHaveValue(expected);
+ });
+
+ test("allows freely editing (e.g. backspacing) the count field without clamping mid-edit", () => {
+ const modules = [createModule(PAGES_MODULE_KINDS.INFO, 0, 1)];
+ renderModulesWithWrapper(modules);
+
+ const countInput = screen.getByTestId("clone-count-input");
+ fireEvent.change(countInput, { target: { value: "15" } });
+ expect(countInput).toHaveValue(15);
+
+ // backspace to clear, as a user retyping the value would
+ fireEvent.change(countInput, { target: { value: "" } });
+ expect(countInput).toHaveValue(null);
+
+ fireEvent.change(countInput, { target: { value: "5" } });
+ expect(countInput).toHaveValue(5);
+ });
+
+ 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);
+ });
+
+ test("pressing Enter in the count field clones the module and prevents the keydown's default action", async () => {
+ // a `false` return from fireEvent means preventDefault() was called on
+ // the keydown event — this is what stops Enter from implicitly
+ // submitting the popup's enclosing