Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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]);
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -617,4 +624,211 @@ describe("PageModules", () => {
});
});
});

describe("Cloning modules", () => {
const renderModulesWithWrapper = (modules) => {
const TestWrapper = () => {
const { values } = useFormikContext();
return (
<>
<PageModules name="modules" />
<div data-testid="module-ids">
{values.modules.map((m) => m._tempId).join(",")}
</div>
<div data-testid="module-has-id">
{values.modules.map((m) => (m.id ? "1" : "0")).join(",")}
</div>
</>
);
};

const store = mockStore({
mediaUploadState: { media_file_types: [] }
});
return render(
<Provider store={store}>
<Formik initialValues={{ modules }} onSubmit={jest.fn()}>
<Form>
<TestWrapper />
</Form>
</Formik>
</Provider>
);
};

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 (
<>
<PageModules name="modules" />
<div data-testid="clone-media-fields">
{JSON.stringify({
type: clone?.type,
max_file_size: clone?.max_file_size,
file_type_id: clone?.file_type_id
})}
</div>
</>
);
};

const store = mockStore({ mediaUploadState: { media_file_types: [] } });
render(
<Provider store={store}>
<Formik initialValues={{ modules }} onSubmit={jest.fn()}>
<Form>
<TestWrapper />
</Form>
</Formik>
</Provider>
);

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 (
<>
<PageModules name="modules" />
<div data-testid="clone-file-1">
{JSON.stringify(values.modules[1]?.file)}
</div>
<div data-testid="clone-file-2">
{JSON.stringify(values.modules[2]?.file)}
</div>
</>
);
};

const store = mockStore({
mediaUploadState: { media_file_types: [] }
});
render(
<Provider store={store}>
<Formik initialValues={{ modules }} onSubmit={jest.fn()}>
<Form>
<TestWrapper />
</Form>
</Formik>
</Provider>
);

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
);
});
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: [] } });
Expand Down
Loading