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",