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
25 changes: 23 additions & 2 deletions src/actions/__tests__/email-actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ import {
normalizeRenderErrors
} from "../email-actions";
import * as methods from "../../utils/methods";

jest.mock("../../history", () => ({ push: jest.fn() }));
import history from "../../history";

jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({
__esModule: true,
Expand All @@ -34,6 +33,11 @@ jest.mock("../marketing-actions", () => ({
saveMarketingSetting: jest.fn()
}));

jest.mock("../../history", () => ({
__esModule: true,
default: { push: jest.fn() }
}));

const requestMock =
(requestActionCreator, receiveActionCreator) => () => (dispatch) => {
if (requestActionCreator && typeof requestActionCreator === "function") {
Expand Down Expand Up @@ -85,6 +89,7 @@ describe("saveEmailTemplate", () => {
jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN");
postRequest.mockImplementation(requestMock);
putRequest.mockImplementation(requestMock);
history.push.mockClear();
});

afterEach(() => {
Expand Down Expand Up @@ -113,6 +118,14 @@ describe("saveEmailTemplate", () => {
actionTypes.indexOf("TEMPLATE_ADDED")
);
});

it("navigates to the new template's edit route using the server-assigned id", async () => {
const store = mockStore({});
store.dispatch(saveEmailTemplate({ identifier: "test-template" }));
await flushPromises();

expect(history.push).toHaveBeenCalledWith("/app/emails/templates/1");
});
});

describe("update path (entity has id)", () => {
Expand All @@ -137,6 +150,14 @@ describe("saveEmailTemplate", () => {
actionTypes.indexOf("TEMPLATE_UPDATED")
);
});

it("does not navigate away", async () => {
const store = mockStore({});
store.dispatch(saveEmailTemplate({ id: 1, identifier: "test-template" }));
await flushPromises();

expect(history.push).not.toHaveBeenCalled();
});
});
});

Expand Down
222 changes: 213 additions & 9 deletions src/components/forms/__tests__/email-template-form.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
afterEach
} from "@jest/globals";
import { render, act, fireEvent } from "@testing-library/react";
import showConfirmDialog from "openstack-uicore-foundation/lib/components/mui/show-confirm-dialog";
import mjml2html from "mjml-browser";

import EmailTemplateForm from "../email-template-form";

Expand All @@ -16,13 +18,16 @@ jest.mock("@uiw/react-codemirror", () => ({
__esModule: true,
default: () => null
}));
jest.mock("sweetalert2", () => ({
__esModule: true,
default: { fire: jest.fn(() => Promise.resolve({})) }
}));
jest.mock(
"openstack-uicore-foundation/lib/components/mui/show-confirm-dialog",
() => ({
__esModule: true,
default: jest.fn(() => Promise.resolve(true))
})
);
jest.mock("mjml-browser", () => ({
__esModule: true,
default: () => ({ html: "<html></html>" })
default: jest.fn(() => ({ html: "<html></html>" }))
}));
jest.mock("../../inputs/email-template-input", () => ({
__esModule: true,
Expand All @@ -31,7 +36,6 @@ jest.mock("../../inputs/email-template-input", () => ({

const baseProps = (entity) => ({
entity,
match: { params: { template_id: `${entity.id}` } },
errors: {},
clients: [],
preview: null,
Expand Down Expand Up @@ -62,7 +66,10 @@ const htmlEntity = {
};

describe("EmailTemplateForm preview dispatch", () => {
beforeEach(() => jest.useFakeTimers());
beforeEach(() => {
jest.useFakeTimers();
showConfirmDialog.mockResolvedValue(true);
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
Expand Down Expand Up @@ -135,7 +142,7 @@ describe("EmailTemplateForm preview dispatch", () => {

it("re-fires the HTML-mode preview when toggled from MJML to HTML", async () => {
const props = baseProps(mjmlEntity);
const { getByDisplayValue } = render(<EmailTemplateForm {...props} />);
const { getByText } = render(<EmailTemplateForm {...props} />);

// initial mount → one MJML-mode request
await act(async () => {
Expand All @@ -152,7 +159,7 @@ describe("EmailTemplateForm preview dispatch", () => {
// mutates neither content field directly
// T.translate returns the key string when no i18n config is loaded
await act(async () => {
fireEvent.click(getByDisplayValue("emails.display_html"));
fireEvent.click(getByText("emails.display_html"));
});
await act(async () => {
jest.advanceTimersByTime(600);
Expand All @@ -166,4 +173,201 @@ describe("EmailTemplateForm preview dispatch", () => {
false
);
});

it("warns before switching to MJML on an HTML-only template and keeps the switch on confirm", async () => {
showConfirmDialog.mockResolvedValue(true);
const props = baseProps(htmlEntity);
const { getByText } = render(<EmailTemplateForm {...props} />);

await act(async () => {
jest.advanceTimersByTime(600);
});

await act(async () => {
fireEvent.click(getByText("emails.display_mjml"));
});

expect(showConfirmDialog).toHaveBeenCalledWith(
expect.objectContaining({
text: "emails.mjml_warning",
iconType: "warning"
})
);

// switch is kept — the button now offers to go back to HTML
expect(getByText("emails.display_html")).toBeTruthy();
});

it("reverts to HTML mode when the MJML switch warning is cancelled", async () => {
showConfirmDialog.mockResolvedValue(false);
const props = baseProps(htmlEntity);
const { getByText } = render(<EmailTemplateForm {...props} />);

await act(async () => {
jest.advanceTimersByTime(600);
});

await act(async () => {
fireEvent.click(getByText("emails.display_mjml"));
});

// reverted back — the button offers to switch to MJML again
expect(getByText("emails.display_mjml")).toBeTruthy();
});

it("does not preview or compile the empty mjml_content while the switch warning is still pending", async () => {
let resolveConfirm;
showConfirmDialog.mockReturnValue(
new Promise((resolve) => {
resolveConfirm = resolve;
})
);
const props = baseProps(htmlEntity);
const { getByText } = render(<EmailTemplateForm {...props} />);

await act(async () => {
jest.advanceTimersByTime(600);
});
props.renderEmailTemplate.mockClear();

fireEvent.click(getByText("emails.display_mjml"));
await act(async () => {
jest.advanceTimersByTime(600);
});

// the dialog hasn't resolved yet -- mode must still be HTML, so no
// preview request went out for the (empty) mjml_content
expect(props.renderEmailTemplate).not.toHaveBeenCalled();
expect(getByText("emails.display_mjml")).toBeTruthy();

await act(async () => {
resolveConfirm(true);
});
});

it("does not attempt to compile mjml on a bare mode switch with unchanged (empty) content", async () => {
const props = baseProps(htmlEntity);
const { getByText } = render(<EmailTemplateForm {...props} />);

await act(async () => {
jest.advanceTimersByTime(600);
});
mjml2html.mockClear();

await act(async () => {
fireEvent.click(getByText("emails.display_mjml"));
});

// switching modes alone must not attempt a compile of the unchanged,
// still-empty mjml_content -- doing so would leave a stale
// mjmlRenderError behind after switching back to HTML
expect(mjml2html).not.toHaveBeenCalled();
});
});

describe("EmailTemplateForm submit", () => {
beforeEach(() => {
jest.useFakeTimers();
showConfirmDialog.mockResolvedValue(true);
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
jest.clearAllMocks();
});

it("submits the current entity and disables the Save button while saving, blocking a double submit", async () => {
let resolveSave;
const onSubmit = jest.fn(
() =>
new Promise((resolve) => {
resolveSave = resolve;
})
);
const props = { ...baseProps(htmlEntity), onSubmit };
const { getByRole } = render(<EmailTemplateForm {...props} />);

await act(async () => {
jest.advanceTimersByTime(600);
});

const saveButton = getByRole("button", { name: "general.save" });
fireEvent.click(saveButton);

expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ id: htmlEntity.id })
);
expect(saveButton).toBeDisabled();

// clicking again while disabled must not call onSubmit a second time
fireEvent.click(saveButton);
expect(onSubmit).toHaveBeenCalledTimes(1);

await act(async () => {
resolveSave();
});
});

it("re-enables the Save button after a rejected save", async () => {
const onSubmit = jest.fn(() => Promise.reject(new Error("save failed")));
const props = { ...baseProps(htmlEntity), onSubmit };
const { getByRole } = render(<EmailTemplateForm {...props} />);

await act(async () => {
jest.advanceTimersByTime(600);
});

const saveButton = getByRole("button", { name: "general.save" });

await act(async () => {
fireEvent.click(saveButton);
});

expect(saveButton).not.toBeDisabled();
});
});

describe("EmailTemplateForm responsive preview scale", () => {
let offsetWidthSpy;

beforeEach(() => {
jest.useFakeTimers();
showConfirmDialog.mockResolvedValue(true);
offsetWidthSpy = jest
.spyOn(HTMLElement.prototype, "offsetWidth", "get")
.mockReturnValue(800);
});

afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
jest.clearAllMocks();
offsetWidthSpy.mockRestore();
});

it("recovers to full scale once the preview container widens after an early narrow measurement", async () => {
// simulate the preview container being measured while still narrow --
// e.g. the surrounding page layout hasn't settled yet on first mount
offsetWidthSpy.mockReturnValue(400);
const props = baseProps(htmlEntity);
const { container } = render(<EmailTemplateForm {...props} />);

await act(async () => {
jest.advanceTimersByTime(600);
});

expect(container.querySelector("iframe").style.transform).toBe(
"scale(0.5)"
);

// the container widens (e.g. the rest of the page layout settles)
offsetWidthSpy.mockReturnValue(800);
await act(async () => {
window.dispatchEvent(new Event("resize"));
});

// FIX: scale must recover to 1 -- pre-fix it stays stuck at 0.5 forever
expect(container.querySelector("iframe").style.transform).toBe("scale(1)");
});
});
Loading
Loading