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
+ const modules = [createModule(PAGES_MODULE_KINDS.INFO, 0, 1)]; + renderModulesWithWrapper(modules); + + const countInput = screen.getByTestId("clone-count-input"); + const dispatchResult = fireEvent.keyDown(countInput, { + key: "Enter", + code: "Enter" + }); + + expect(dispatchResult).toBe(false); + + await waitFor(() => { + expect(screen.getByTestId("module-ids")).toHaveTextContent( + /^temp-1,temp-clone-\d+$/ + ); + }); + }); + + describe("Document Download clone behavior", () => { + const createDocumentModule = (overrides) => ({ + _tempId: "temp-doc-1", + kind: PAGES_MODULE_KINDS.DOCUMENT, + custom_order: 0, + name: "Doc", + description: "Desc", + type: PAGE_MODULES_DOWNLOAD.FILE, + external_url: "", + file: null, + ...overrides + }); + + test("type=File, already-uploaded file: disables Clone and does not create copies", () => { + const modules = [ + createDocumentModule({ + file: [{ id: 10, file_url: "http://x/file.pdf" }] + }) + ]; + + const TestWrapper = () => { + const { values } = useFormikContext(); + return ( + <> + +
{values.modules.length}
+ + ); + }; + + const store = mockStore({ + mediaUploadState: { media_file_types: [] } + }); + render( + + + + + + + + ); + + const cloneButton = screen.getByTestId("clone-module-btn"); + expect(cloneButton).toBeDisabled(); + + // native `disabled` blocks the click outright; use fireEvent since + // userEvent additionally refuses to interact with pointer-events:none + // elements (which MUI also sets on disabled buttons) + fireEvent.click(cloneButton); + + expect(screen.getByTestId("module-count")).toHaveTextContent("1"); + }); + + test("type=File, already-uploaded file: pressing Enter while Clone is disabled does not clone", () => { + const modules = [ + createDocumentModule({ + file: [{ id: 10, file_url: "http://x/file.pdf" }] + }) + ]; + + const TestWrapper = () => { + const { values } = useFormikContext(); + return ( + <> + +
{values.modules.length}
+ + ); + }; + + const store = mockStore({ + mediaUploadState: { media_file_types: [] } + }); + render( + + +
+ + +
+
+ ); + + const countInput = screen.getByTestId("clone-count-input"); + expect(countInput).toBeDisabled(); + + // fireEvent dispatches the keydown regardless of the native disabled + // attribute, exercising the `if (disabled) return;` guard in + // handleClone rather than any browser-level event gating + fireEvent.keyDown(countInput, { key: "Enter", code: "Enter" }); + + expect(screen.getByTestId("module-count")).toHaveTextContent("1"); + }); + + test.each([ + ["file: null (default, nothing chosen yet)", { file: null }], + ["file: [] (nothing chosen yet)", { file: [] }] + ])( + "type=File, no file chosen yet (%s): keeps Clone enabled and clones normally", + async (_description, moduleOverrides) => { + const modules = [createDocumentModule(moduleOverrides)]; + + const TestWrapper = () => { + const { values } = useFormikContext(); + return ( + <> + +
{values.modules.length}
+ + ); + }; + + const store = mockStore({ + mediaUploadState: { media_file_types: [] } + }); + render( + + +
+ + +
+
+ ); + + const cloneButton = screen.getByTestId("clone-module-btn"); + expect(cloneButton).not.toBeDisabled(); + + await userEvent.click(cloneButton); + + expect(screen.getByTestId("module-count")).toHaveTextContent("2"); + } + ); + + test.each([ + [ + "type=File, newly selected file: copies file as-is to every clone", + { file: [{ name: "new-upload.pdf" }] }, + { + externalUrl: "", + file: JSON.stringify([{ name: "new-upload.pdf" }]) + } + ], + [ + "type=Url: copies external_url as-is and clears a non-new file (guards against a type switch bringing back a persisted file)", + { + type: PAGE_MODULES_DOWNLOAD.URL, + external_url: "https://example.com/doc" + }, + { externalUrl: "https://example.com/doc", file: "[]" } + ] + ])("%s", async (_description, moduleOverrides, expected) => { + const modules = [createDocumentModule(moduleOverrides)]; + + const TestWrapper = () => { + const { values } = useFormikContext(); + return ( + <> + +
+ {JSON.stringify(values.modules[1]?.external_url)} +
+
+ {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( + expected.file + ); + expect(screen.getByTestId("clone-file-2")).toHaveTextContent( + expected.file + ); + }); + expect(screen.getByTestId("clone-external-url-1")).toHaveTextContent( + JSON.stringify(expected.externalUrl) + ); + }); + }); + }); }); 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..98661565b --- /dev/null +++ b/src/pages/sponsors-global/page-templates/page-template-popup/module-clone-control.js @@ -0,0 +1,102 @@ +import React, { useState } from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import { Box, Button, InputAdornment, TextField, Tooltip } 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, + disabled = false, + disabledReason = "" +}) => { + const [count, setCount] = useState(String(MIN_MODULE_CLONE_COUNT)); + + const handleCountChange = (e) => { + setCount(e.target.value); + }; + + const handleCountBlur = () => { + setCount(String(clampCloneCount(parseInt(count, 10)))); + }; + + const handleClone = () => { + if (disabled) return; + onClone(clampCloneCount(parseInt(count, 10))); + setCount(String(MIN_MODULE_CLONE_COUNT)); + }; + + return ( + e.stopPropagation()} + > + { + if (e.key === "Enter") { + e.preventDefault(); + handleClone(); + } + }} + onBlur={handleCountBlur} + slotProps={{ + input: { + startAdornment: 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, + disabled: PropTypes.bool, + disabledReason: PropTypes.string +}; + +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..ec1bae278 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,12 +18,15 @@ 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"; +import { isNewDocumentFile } from "../../../../utils/page-template"; const PageModules = ({ name = "modules", @@ -37,15 +40,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 +123,67 @@ const PageModules = ({ } }; + const getDocumentFile = (module) => + Array.isArray(module.file) ? module.file[0] : null; + + // 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 = getDocumentFile(module); + return isNewDocumentFile(file) ? module.file : []; + }; + + // Cloning a Document module whose file is already persisted would create + // copies with no file (see buildClonedDocumentFile above) that fail the + // documentModuleSchema's required file validation and block saving the + // whole template. Disable Clone for that case instead of letting the user + // hit an unsaveable dead end after the fact. A module with no file at all + // yet (nothing chosen) is not "persisted" and must stay clonable. + const isCloneBlockedByPersistedFile = (module) => { + if ( + module.kind !== PAGES_MODULE_KINDS.DOCUMENT || + module.type !== PAGE_MODULES_DOWNLOAD.FILE + ) { + return false; + } + const file = getDocumentFile(module); + return Boolean(file) && !isNewDocumentFile(file); + }; + + const handleCloneModule = (index, module, count) => { + const isDocument = module.kind === PAGES_MODULE_KINDS.DOCUMENT; + const clonedFile = isDocument ? buildClonedDocumentFile(module) : null; + + const sourceData = { ...module }; + delete sourceData.id; + + const clones = Array.from({ length: count }, () => { + cloneIdCounter.current += 1; + return { + ...sourceData, + ...(isDocument ? { 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); }; @@ -145,68 +219,83 @@ const PageModules = ({ } }; - const renderModule = (module, index) => ( - { - moduleRefMap.current.set(getModuleId(module), el); - }} - sx={{ - mb: 1, - "&:before": { display: "none" }, - boxShadow: "none", - border: "1px solid #e0e0e0", - borderRadius: "0 !important", - "&:first-of-type": { borderRadius: 0 }, - "&:last-of-type": { borderRadius: 0 } - }} - key={module._tempId || `module-${index}`} - > - } + const renderModule = (module, index) => { + const cloneDisabled = isCloneBlockedByPersistedFile(module); + + return ( + { + moduleRefMap.current.set(getModuleId(module), el); + }} sx={{ - backgroundColor: "#2196F31F", - flexDirection: "row-reverse", - "& .MuiAccordionSummary-expandIconWrapper": { - marginRight: 1, - marginLeft: 0 - } + mb: 1, + "&:before": { display: "none" }, + boxShadow: "none", + border: "1px solid #e0e0e0", + borderRadius: "0 !important", + "&:first-of-type": { borderRadius: 0 }, + "&:last-of-type": { borderRadius: 0 } }} + key={module._tempId || `module-${index}`} > - } sx={{ - display: "flex", - alignItems: "center", - width: "100%", - justifyContent: "space-between" + backgroundColor: "#2196F31F", + flexDirection: "row-reverse", + "& .MuiAccordionSummary-expandIconWrapper": { + marginRight: 1, + marginLeft: 0 + } }} > - {getModuleTitle(module.kind)} - e.stopPropagation()} + sx={{ + display: "flex", + alignItems: "center", + width: "100%", + justifyContent: "space-between" + }} > - - handleDeleteModule(index, module)} + {getModuleTitle(module.kind)} + + e.stopPropagation()} > - - + handleCloneModule(index, module, count)} + disabled={cloneDisabled} + disabledReason={ + cloneDisabled + ? T.translate( + "page_template_list.page_crud.clone_disabled_persisted_file" + ) + : "" + } + /> + + handleDeleteModule(index, module)} + > + + + - - - - {renderModuleFields(module, index)} - - - ); + + + {renderModuleFields(module, index)} + + + ); + }; return ( @@ -220,7 +309,16 @@ const PageModules = ({ ) : ( ({ + ...module, + _tempId: getModuleId(module) + }))} onReorder={handleReorderModules} renderItem={renderModule} idKey="_tempId" 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", diff --git a/src/utils/page-template.js b/src/utils/page-template.js index 775be4541..3bef01199 100644 --- a/src/utils/page-template.js +++ b/src/utils/page-template.js @@ -37,6 +37,11 @@ export const denormalizePageModules = (modules = [], timeZone = null) => return tmpModule; }); +// A file is "new" when it was just selected in the browser and has not been +// uploaded/persisted yet — persisted files carry an `id` or `file_id`. +export const isNewDocumentFile = (file) => + Boolean(file && typeof file === "object" && !file.id && !file.file_id); + export const normalizePageTemplateModules = (modules = [], timeZone = null) => modules.map((module) => { const normalizedModule = { ...module }; @@ -65,9 +70,7 @@ export const normalizePageTemplateModules = (modules = [], timeZone = null) => if (module.type === PAGE_MODULES_DOWNLOAD.FILE) { // Only new files (without id or file_id) are sent in the payload; existing files are omitted to prevent overwriting. const file = Array.isArray(module.file) ? module.file[0] : null; - const isNewFile = - file && typeof file === "object" && !file.id && !file.file_id; - if (isNewFile) { + if (isNewDocumentFile(file)) { normalizedModule.file = file; } else { delete normalizedModule.file;