diff --git a/src/actions/__tests__/email-actions.test.js b/src/actions/__tests__/email-actions.test.js
index 8431c6a0f..7a3fd5cc9 100644
--- a/src/actions/__tests__/email-actions.test.js
+++ b/src/actions/__tests__/email-actions.test.js
@@ -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,
@@ -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") {
@@ -85,6 +89,7 @@ describe("saveEmailTemplate", () => {
jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN");
postRequest.mockImplementation(requestMock);
putRequest.mockImplementation(requestMock);
+ history.push.mockClear();
});
afterEach(() => {
@@ -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)", () => {
@@ -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();
+ });
});
});
diff --git a/src/components/forms/__tests__/email-template-form.test.js b/src/components/forms/__tests__/email-template-form.test.js
index a90b57e15..e7afd2095 100644
--- a/src/components/forms/__tests__/email-template-form.test.js
+++ b/src/components/forms/__tests__/email-template-form.test.js
@@ -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";
@@ -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: "" })
+ default: jest.fn(() => ({ html: "" }))
}));
jest.mock("../../inputs/email-template-input", () => ({
__esModule: true,
@@ -31,7 +36,6 @@ jest.mock("../../inputs/email-template-input", () => ({
const baseProps = (entity) => ({
entity,
- match: { params: { template_id: `${entity.id}` } },
errors: {},
clients: [],
preview: null,
@@ -62,7 +66,10 @@ const htmlEntity = {
};
describe("EmailTemplateForm preview dispatch", () => {
- beforeEach(() => jest.useFakeTimers());
+ beforeEach(() => {
+ jest.useFakeTimers();
+ showConfirmDialog.mockResolvedValue(true);
+ });
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
@@ -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();
+ const { getByText } = render();
// initial mount → one MJML-mode request
await act(async () => {
@@ -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);
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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)");
+ });
});
diff --git a/src/components/forms/email-template-form.js b/src/components/forms/email-template-form.js
index 7acba8e57..568cf3cab 100644
--- a/src/components/forms/email-template-form.js
+++ b/src/components/forms/email-template-form.js
@@ -11,19 +11,21 @@
* limitations under the License.
* */
-import React, { useState, useEffect, useRef } from "react";
+import React, { useState, useEffect, useMemo, useRef } from "react";
import T from "i18n-react/dist/i18n-react";
-import "awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css";
import debounce from "lodash/debounce";
-import AjaxLoader from "openstack-uicore-foundation/lib/components/ajaxloader";
-import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown";
-import Input from "openstack-uicore-foundation/lib/components/inputs/text-input";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import Grid2 from "@mui/material/Grid2";
+import TextField from "@mui/material/TextField";
+import CircularProgress from "@mui/material/CircularProgress";
+import MuiDropdown from "openstack-uicore-foundation/lib/components/mui/dropdown";
import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods";
import CodeMirror from "@uiw/react-codemirror";
import { sublimeInit } from "@uiw/codemirror-theme-sublime";
import { html } from "@codemirror/lang-html";
import mjml2html from "mjml-browser";
-import Swal from "sweetalert2";
+import showConfirmDialog from "openstack-uicore-foundation/lib/components/mui/show-confirm-dialog";
import EmailTemplateInput from "../inputs/email-template-input";
import { scrollToError, shallowEqual, hasErrors } from "../../utils/methods";
import "./email-template.less";
@@ -49,7 +51,6 @@ const default_mjml_content = `
const EmailTemplateForm = ({
entity,
- match,
errors,
clients,
preview,
@@ -62,7 +63,7 @@ const EmailTemplateForm = ({
}) => {
const [stateEntity, setStateEntity] = useState({ ...entity });
const [stateErrors, setStateErrors] = useState(errors);
- const [historyVersion, setHistoryVersion] = useState(null);
+ const [historyVersion, setHistoryVersion] = useState("");
const [currentVersionExternalLink, setCurrentVersionExternalLink] =
useState(null);
const [mjmlEditor, setMjmlEditor] = useState(null);
@@ -75,8 +76,11 @@ const EmailTemplateForm = ({
const [previewLoaded, setPreviewLoaded] = useState(false);
const [mjmlWarning, setMjmlWarning] = useState(false);
const [mjmlRenderError, setMjmlRenderError] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
const previewRef = useRef(null);
+ // undefined so the very first run below is always treated as a new entity
+ const loadedEntityIdRef = useRef();
const style = mobileView
? { width: "320px", height: "960px", transform: `scale(${scale})` }
@@ -85,39 +89,30 @@ const EmailTemplateForm = ({
useEffect(() => {
scrollToError(errors);
- // check if the current entity is sync with template_id param
- const templateId = match.params.template_id;
- if (
- templateId === `${entity.id}` ||
- templateId === entity.identifier ||
- (entity.id === 0 && !templateId)
- ) {
- setTemplateLoaded(true);
- }
-
if (!shallowEqual(stateErrors, errors)) {
setStateErrors({ ...errors });
}
- if (!shallowEqual(stateEntity, entity)) {
- setStateEntity({ ...entity });
- }
- }, [errors, entity]);
+ const isNewEntity = loadedEntityIdRef.current !== entity.id;
+ loadedEntityIdRef.current = entity.id;
- useEffect(() => {
- // if entity is correctly loaded, set state for entity use
- if (templateLoaded) {
- if (entity.id === 0) {
- setStateEntity({ ...entity, mjml_content: default_mjml_content });
- } else {
- setStateEntity({ ...entity });
- }
+ if (isNewEntity) {
+ // a fresh load, a route change to a different template, or the id the
+ // server assigns right after a successful create -- (re)seed local state
+ setStateEntity(
+ entity.id === 0
+ ? { ...entity, mjml_content: default_mjml_content }
+ : { ...entity }
+ );
setStateErrors({});
setMjmlEditor(
entity.mjml_content.length > 0 ? true : !entity.html_content
);
+ setTemplateLoaded(true);
+ } else if (!shallowEqual(stateEntity, entity)) {
+ setStateEntity({ ...entity });
}
- }, [templateLoaded, entity.id]);
+ }, [errors, entity]);
useEffect(() => {
if (singleTab) {
@@ -131,69 +126,83 @@ const EmailTemplateForm = ({
const DEBOUNCE_MS = 500;
const debouncedRenderTemplate = useRef(
debounce(async (content, json_data, isMjml) => {
- renderEmailTemplate(json_data, content, isMjml).then(() => {
- // wait until first API email preview to display template on screen
- if (!previewLoaded) setPreviewLoaded(true);
- });
+ renderEmailTemplate(json_data, content, isMjml)
+ .then(() => {
+ // wait until first API email preview to display template on screen
+ if (!previewLoaded) setPreviewLoaded(true);
+ })
+ .catch(() => {});
}, DEBOUNCE_MS)
).current;
- // MJML mode: send raw mjml_content so the API runs Jinja -> official MJML CLI
- // (same pipeline as production). mjmlEditor is in the deps so a button-only
- // mode switch re-fires this; the debounce coalesces with the HTML effect so
- // only one preview request goes out per mode.
- useEffect(() => {
- if (templateLoaded && mjmlEditor)
- debouncedRenderTemplate(stateEntity.mjml_content, templateJsonData, true);
- }, [stateEntity.mjml_content, mjmlEditor, entity, templateJsonData]);
+ // MJML mode sends raw mjml_content so the API runs Jinja -> official MJML CLI
+ // (same pipeline as production); HTML mode sends html_content unchanged.
+ // mjmlEditor is in the deps so a button-only mode switch re-fires this with
+ // the other field's content.
+ const editorContent = mjmlEditor
+ ? stateEntity.mjml_content
+ : stateEntity.html_content;
- // HTML mode: unchanged Jinja-on-HTML preview. Guarded on !mjmlEditor so it
- // does not fire for MJML templates.
useEffect(() => {
- if (templateLoaded && !mjmlEditor)
- debouncedRenderTemplate(
- stateEntity.html_content,
- templateJsonData,
- false
- );
- }, [stateEntity.html_content, mjmlEditor, entity, templateJsonData]);
-
- useEffect(() => {
- if (mjmlEditor) {
- try {
- const htmlContent = mjml2html(stateEntity.mjml_content, {
- validationLevel: "strict",
- keepComments: false,
- collapseWhitespace: true,
- minifyOptions: { collapseWhitespace: false }
- }).html;
- setStateEntity({ ...stateEntity, html_content: htmlContent });
- setMjmlRenderError(null);
- } catch (err) {
- setMjmlRenderError(err);
- }
+ if (templateLoaded)
+ debouncedRenderTemplate(editorContent, templateJsonData, mjmlEditor);
+ }, [editorContent, mjmlEditor, entity, templateJsonData, templateLoaded]);
+
+ // pure compile step -- useMemo avoids re-running mjml2html on every render,
+ // the effect below only commits the already-computed result into state
+ const mjmlCompileResult = useMemo(() => {
+ if (!mjmlEditor) return null;
+ try {
+ const htmlContent = mjml2html(stateEntity.mjml_content, {
+ validationLevel: "strict",
+ keepComments: false,
+ collapseWhitespace: true,
+ minifyOptions: { collapseWhitespace: false }
+ }).html;
+ return { htmlContent, error: null };
+ } catch (err) {
+ return { htmlContent: null, error: err };
}
}, [stateEntity.mjml_content, historyVersion]);
useEffect(() => {
- if (
+ if (!mjmlCompileResult) return;
+ setMjmlRenderError(mjmlCompileResult.error);
+ if (mjmlCompileResult.htmlContent !== null) {
+ setStateEntity({
+ ...stateEntity,
+ html_content: mjmlCompileResult.htmlContent
+ });
+ }
+ }, [mjmlCompileResult]);
+
+ // gate the confirm dialog BEFORE flipping mjmlEditor -- flipping it first and
+ // asking after (the previous shape) let the preview/compile effects fire on
+ // the still-empty mjml_content while the dialog was still pending
+ const handleDisplayMjml = () => {
+ const needsMjmlWarning =
entity.mjml_content.length === 0 &&
entity.html_content.length > 0 &&
- mjmlEditor &&
- !mjmlWarning
- ) {
- console.log("warning mjml");
- Swal.fire({
- title: T.translate("general.are_you_sure"),
- text: T.translate("emails.mjml_warning"),
- type: "warning",
- confirmButtonColor: "#DD6B55",
- confirmButtonText: T.translate("emails.understand")
- }).then(() => {
- setMjmlWarning(true);
- });
+ !mjmlWarning;
+
+ if (!needsMjmlWarning) {
+ setMjmlEditor(true);
+ return;
}
- }, [mjmlEditor]);
+
+ showConfirmDialog({
+ title: T.translate("general.are_you_sure"),
+ text: T.translate("emails.mjml_warning"),
+ iconType: "warning",
+ confirmButtonColor: "error",
+ confirmButtonText: T.translate("emails.understand")
+ }).then((confirmed) => {
+ if (confirmed) {
+ setMjmlWarning(true);
+ setMjmlEditor(true);
+ }
+ });
+ };
const handleCodeMirrorHTMLChange = (value) => {
setStateErrors({ ...stateErrors, html_content: "" });
@@ -220,9 +229,9 @@ const EmailTemplateForm = ({
setStateErrors({ ...stateErrors, [id]: "" });
};
- const handleSubmit = (ev) => {
- ev.preventDefault();
- onSubmit(stateEntity);
+ const handleClientsChange = (ev) => {
+ setStateEntity({ ...stateEntity, allowed_clients: ev.target.value });
+ setStateErrors({ ...stateErrors, allowed_clients: "" });
};
const handleJsonDataEdit = (ev) => {
@@ -241,15 +250,16 @@ const EmailTemplateForm = ({
setSingleTab(false);
}
const currentPreviewWidth = previewRef?.current?.offsetWidth;
- if (mobileView) {
- if (currentPreviewWidth < MOBILE_PREVIEW_WIDTH) {
- const newScale = currentPreviewWidth / MOBILE_PREVIEW_WIDTH;
- setScale(newScale);
- }
- } else if (currentPreviewWidth < DESKTOP_PREVIEW_WIDTH) {
- const newScale = currentPreviewWidth / DESKTOP_PREVIEW_WIDTH;
- setScale(newScale);
- }
+ if (!currentPreviewWidth) return;
+ const targetWidth = mobileView
+ ? MOBILE_PREVIEW_WIDTH
+ : DESKTOP_PREVIEW_WIDTH;
+ // always recompute the full ratio -- shrink to fit when the container is
+ // narrower than the target, but also grow back to 1 once there is room
+ // again (a narrow measurement early in the mount sequence must not
+ // permanently lock the preview at a reduced scale)
+ const newScale = Math.min(1, currentPreviewWidth / targetWidth);
+ setScale(newScale);
};
const handleTabChange = (ev) => {
@@ -277,14 +287,14 @@ const EmailTemplateForm = ({
const handleVersionChange = (ev) => {
const { value } = ev.target;
- if (value === null) {
+ if (!value) {
// restore original version
setStateEntity({
...stateEntity,
html_content: stateEntity.original_html_content,
mjml_content: stateEntity.original_mjml_content
});
- setHistoryVersion(null);
+ setHistoryVersion("");
setCurrentVersionExternalLink(null);
return;
}
@@ -294,62 +304,95 @@ const EmailTemplateForm = ({
setCurrentVersionExternalLink(selectedHistory.html_url);
if (selectedHistory.type === EMAIL_TEMPLATE_TYPE_HTML) {
setMjmlEditor(false);
- setStateEntity({ ...stateEntity, html_content: selectedHistory.content });
+ setStateEntity({
+ ...stateEntity,
+ html_content: selectedHistory.content
+ });
}
if (selectedHistory.type === EMAIL_TEMPLATE_TYPE_MJML) {
setMjmlEditor(true);
- setStateEntity({ ...stateEntity, mjml_content: selectedHistory.content });
+ setStateEntity({
+ ...stateEntity,
+ mjml_content: selectedHistory.content
+ });
}
};
const isTemplateInvalid = () => mjmlEditor && mjmlRenderError !== null;
+ // recompute whenever a layout-affecting toggle changes the preview
+ // container's rendered width (not just on an actual window resize) --
+ // templateLoaded matters too: the preview container doesn't exist to
+ // measure until that first flips true
useEffect(() => {
handleResizeWindow();
- window.addEventListener("resize", handleResizeWindow);
+ }, [mobileView, templateLoaded, codeOnly, previewOnly, singleTab]);
+
+ // bind the native listener once; the ref keeps it pointed at the latest
+ // closure so a real resize still sees current state without rebinding
+ const handleResizeWindowRef = useRef(handleResizeWindow);
+ handleResizeWindowRef.current = handleResizeWindow;
+
+ useEffect(() => {
+ const onResize = () => handleResizeWindowRef.current();
+ window.addEventListener("resize", onResize);
return () => {
- window.removeEventListener("resize", handleResizeWindow);
+ window.removeEventListener("resize", onResize);
};
- });
+ }, []);
+
+ const handleSubmit = () => {
+ if (isSaving) return;
+ setIsSaving(true);
+ Promise.resolve(onSubmit(stateEntity))
+ .catch(() => {})
+ .finally(() => setIsSaving(false));
+ };
const email_clients_ddl = clients
? clients.map((cli) => ({ label: cli.name, value: cli.id }))
: [];
const versions_ddl = stateEntity.versions
- ? stateEntity.versions.map((v) => ({
- label: `${epochToMomentTimeZone(v.commit_date, "UTC").format(
- "YYYY-MM-DD HH:mm z"
- )} - ${v.sha} - ${v.commit_message}`,
- value: v.sha
- }))
+ ? [
+ { value: "", label: T.translate("emails.current_version") },
+ ...stateEntity.versions.map((v) => ({
+ label: `${epochToMomentTimeZone(v.commit_date, "UTC").format(
+ "YYYY-MM-DD HH:mm z"
+ )} - ${v.sha} - ${v.commit_message}`,
+ value: v.sha
+ }))
+ ]
: [];
return (
);
};
diff --git a/src/components/inputs/__tests__/email-template-input.test.js b/src/components/inputs/__tests__/email-template-input.test.js
new file mode 100644
index 000000000..92e69f54a
--- /dev/null
+++ b/src/components/inputs/__tests__/email-template-input.test.js
@@ -0,0 +1,143 @@
+import React from "react";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom";
+import EmailTemplateInput from "../email-template-input";
+import { queryTemplates } from "../../../actions/email-actions";
+
+jest.mock("../../../actions/email-actions", () => ({
+ queryTemplates: jest.fn()
+}));
+
+describe("EmailTemplateInput", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("selects an option, emits the object shape by default, and does not re-search", async () => {
+ queryTemplates.mockImplementation((input, callback) => {
+ callback([{ id: 42, identifier: "welcome_email" }]);
+ });
+ const onChange = jest.fn();
+
+ render();
+
+ const input = screen.getByRole("combobox");
+ await userEvent.type(input, "welcome");
+
+ expect(queryTemplates).toHaveBeenCalledWith(
+ "welcome",
+ expect.any(Function)
+ );
+
+ const option = await screen.findByText("welcome_email");
+ const callsBeforeSelect = queryTemplates.mock.calls.length;
+ await userEvent.click(option);
+
+ expect(onChange).toHaveBeenCalledWith({
+ target: {
+ id: "parent",
+ value: { id: "42", identifier: "welcome_email" },
+ type: "emailtemplateinput"
+ }
+ });
+ // picking an option programmatically fills the input with its label --
+ // that must not trigger a further search
+ expect(queryTemplates).toHaveBeenCalledTimes(callsBeforeSelect);
+ });
+
+ it("emits the plain identifier when plainValue is set", async () => {
+ queryTemplates.mockImplementation((input, callback) => {
+ callback([{ id: 42, identifier: "welcome_email" }]);
+ });
+ const onChange = jest.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByRole("combobox");
+ await userEvent.type(input, "welcome");
+
+ const option = await screen.findByText("welcome_email");
+ await userEvent.click(option);
+
+ expect(onChange).toHaveBeenCalledWith({
+ target: {
+ id: "template_filter",
+ value: "welcome_email",
+ type: "emailtemplateinput"
+ }
+ });
+ });
+
+ it("excludes the owner from the returned options", async () => {
+ queryTemplates.mockImplementation((input, callback) => {
+ callback([
+ { id: 1, identifier: "self" },
+ { id: 2, identifier: "other" }
+ ]);
+ });
+
+ render(
+
+ );
+
+ const input = screen.getByRole("combobox");
+ await userEvent.type(input, "e");
+
+ const listbox = await screen.findByRole("listbox");
+ expect(within(listbox).queryByText("self")).not.toBeInTheDocument();
+ expect(within(listbox).getByText("other")).toBeInTheDocument();
+ });
+
+ it("clears the value with the object shape when not plainValue", async () => {
+ queryTemplates.mockImplementation((input, callback) => callback([]));
+ const onChange = jest.fn();
+
+ render(
+
+ );
+
+ const clearButton = screen.getByLabelText(/clear/i);
+ await userEvent.click(clearButton);
+
+ expect(onChange).toHaveBeenCalledWith({
+ target: {
+ id: "parent",
+ value: { id: "", identifier: "" },
+ type: "emailtemplateinput"
+ }
+ });
+ });
+
+ it("loads default options on mount when defaultOptions is set", () => {
+ queryTemplates.mockImplementation((input, callback) => callback([]));
+
+ render(
+
+ );
+
+ expect(queryTemplates).toHaveBeenCalledWith("", expect.any(Function));
+ });
+});
diff --git a/src/components/inputs/email-template-input.js b/src/components/inputs/email-template-input.js
index 87f4871bd..757af5020 100644
--- a/src/components/inputs/email-template-input.js
+++ b/src/components/inputs/email-template-input.js
@@ -9,89 +9,140 @@
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
- **/
+ * */
-import React from "react";
-import AsyncSelect from "react-select/lib/Async";
+import React, { useEffect, useState } from "react";
+import PropTypes from "prop-types";
+import Autocomplete from "@mui/material/Autocomplete";
+import TextField from "@mui/material/TextField";
+import CircularProgress from "@mui/material/CircularProgress";
import { queryTemplates } from "../../actions/email-actions";
-export default class EmailTemplateInput extends React.Component {
- constructor(props) {
- super(props);
+const EmailTemplateInput = ({
+ id,
+ value,
+ onChange,
+ ownerId,
+ placeholder,
+ error,
+ plainValue,
+ defaultOptions
+}) => {
+ const [options, setOptions] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const fetchOptions = (input) => {
+ setLoading(true);
+ queryTemplates(input, (templates) => {
+ const filtered = ownerId
+ ? templates.filter((t) => t.id !== ownerId)
+ : templates;
+ setOptions(
+ filtered.map((t) => ({ value: t.id.toString(), label: t.identifier }))
+ );
+ setLoading(false);
+ });
+ };
+
+ useEffect(() => {
+ if (defaultOptions) fetchOptions("");
+ }, []);
+
+ const handleInputChange = (ev, input, reason) => {
+ // Autocomplete also fires this for "selectOption"/"reset" (the input text
+ // set programmatically) -- only a real keystroke or a clear should re-search.
+ if (reason !== "input" && reason !== "clear") return;
- this.handleChange = this.handleChange.bind(this);
- this.getTemplates = this.getTemplates.bind(this);
- }
+ if (!input && !defaultOptions) {
+ setOptions([]);
+ return;
+ }
+ fetchOptions(input);
+ };
- handleChange(value, { action }) {
- const { plainValue } = this.props;
- let theValue = null;
+ const handleChange = (ev, newValue) => {
+ let theValue;
- if (action === "clear") {
+ if (!newValue) {
theValue = plainValue ? "" : { id: "", identifier: "" };
} else {
theValue = plainValue
- ? value.label
- : { id: value.value, identifier: value.label };
+ ? newValue.label
+ : { id: newValue.value, identifier: newValue.label };
}
- const ev = {
- target: {
- id: this.props.id,
- value: theValue,
- type: "emailtemplateinput"
- }
- };
+ onChange({ target: { id, value: theValue, type: "emailtemplateinput" } });
+ };
- this.props.onChange(ev);
+ let selectedOption = null;
+ if (value) {
+ selectedOption = plainValue
+ ? { value, label: value }
+ : { value: String(value.id ?? ""), label: value.identifier ?? "" };
}
- getTemplates(input, callback) {
- const { ownerId, defaultOptions } = this.props;
-
- if (!input && !defaultOptions) {
- return Promise.resolve({ options: [] });
- }
-
- // we need to map into value/label because of a bug in react-select 2
- // https://github.com/JedWatson/react-select/issues/2998
-
- const translateOptions = (options) => {
- const newOptions = (
- ownerId ? options.filter((t) => t.id !== ownerId) : options
- ).map((c) => ({ value: c.id.toString(), label: c.identifier }));
- callback(newOptions);
- };
-
- queryTemplates(input, translateOptions);
- }
-
- render() {
- const { error, value, onChange, id, multi, plainValue, ...rest } =
- this.props;
- const has_error = this.props.hasOwnProperty("error") && error !== "";
-
- // we need to map into value/label because of a bug in react-select 2
- // https://github.com/JedWatson/react-select/issues/2998
- let theValue = null;
-
- if (value) {
- theValue = plainValue
- ? { value: value, label: value }
- : { value: value.id.toString(), label: value.identifier };
- }
-
- return (
-
-
o.value === selectedOption.value)
+ ? [selectedOption, ...options]
+ : options;
+
+ return (
+
+ option.value === selected.value
+ }
+ getOptionLabel={(option) => option.label || ""}
+ onChange={handleChange}
+ onInputChange={handleInputChange}
+ renderInput={(params) => (
+
+ {loading && }
+ {params.InputProps.endAdornment}
+ >
+ )
+ }
+ }}
/>
- {has_error && {error}
}
-
- );
- }
-}
+ )}
+ />
+ );
+};
+
+EmailTemplateInput.propTypes = {
+ id: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ onChange: PropTypes.func.isRequired,
+ ownerId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ placeholder: PropTypes.string,
+ error: PropTypes.string,
+ plainValue: PropTypes.bool,
+ defaultOptions: PropTypes.bool
+};
+
+EmailTemplateInput.defaultProps = {
+ value: null,
+ ownerId: null,
+ placeholder: "",
+ error: "",
+ plainValue: false,
+ defaultOptions: false
+};
+
+export default EmailTemplateInput;
diff --git a/src/i18n/en.json b/src/i18n/en.json
index c3c0e36b2..ebf2258e0 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -3285,6 +3285,7 @@
"no_templates": "No templates found for this search criteria.",
"no_emails": "No emails found for this search criteria.",
"previous_template": "Previous Template version",
+ "current_version": "Current version",
"id": "Id",
"name": "Name (alphanumeric)",
"parent": "Parent",
@@ -3307,6 +3308,8 @@
"preview": "Preview",
"sample_data": "Sample Data",
"sample_data_legend": "* You could use this data as it is or your could edit it",
+ "invalid_json": "Invalid JSON, please fix it before updating.",
+ "loading_template": "Loading template...",
"mjml_warning": "Editing an MJML template will overwrite the content from the current HTML content",
"understand": "I understand",
"render": "Render",
diff --git a/src/layouts/email-layout.js b/src/layouts/email-layout.js
index 28f048617..4b1e131b4 100644
--- a/src/layouts/email-layout.js
+++ b/src/layouts/email-layout.js
@@ -9,7 +9,7 @@
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
- **/
+ * */
import React from "react";
import { Switch, Route, Redirect } from "react-router-dom";
@@ -21,48 +21,42 @@ import EmailTemplateListPage from "../pages/emails/email-template-list-page";
import EditEmailTemplatePage from "../pages/emails/edit-email-template-page";
import EmailLogListPage from "../pages/emails/email-log-list-page";
-class EmailLayout extends React.Component {
- render() {
- const { match, currentSummit } = this.props;
+const EmailLayout = ({ match }) => (
+
+
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
+
+
+
+
+
+
+
+
+);
const mapStateToProps = ({ currentSummitState }) => ({
...currentSummitState
diff --git a/src/pages/emails/__tests__/edit-email-template-page.test.js b/src/pages/emails/__tests__/edit-email-template-page.test.js
new file mode 100644
index 000000000..9419cb393
--- /dev/null
+++ b/src/pages/emails/__tests__/edit-email-template-page.test.js
@@ -0,0 +1,246 @@
+import React from "react";
+import { act, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom";
+import flushPromises from "flush-promises";
+import { renderWithRedux } from "../../../utils/test-utils";
+import EditEmailTemplatePage from "../edit-email-template-page";
+import {
+ getEmailTemplate,
+ resetTemplateForm,
+ saveEmailTemplate,
+ getAllClients,
+ updateTemplateJsonData
+} from "../../../actions/email-actions";
+
+jest.mock("../../../actions/email-actions", () => ({
+ getEmailTemplate: jest.fn(),
+ resetTemplateForm: jest.fn(),
+ saveEmailTemplate: jest.fn(),
+ getAllClients: jest.fn(),
+ renderEmailTemplate: jest.fn(),
+ updateTemplateJsonData: jest.fn()
+}));
+
+jest.mock("../../../components/forms/email-template-form", () => ({
+ __esModule: true,
+ default: ({ onSubmit, onRender }) => (
+
+
+
+
+ )
+}));
+
+jest.mock("../email-template-json-dialog", () => ({
+ __esModule: true,
+ default: ({ open, onUpdate, onClose }) =>
+ open ? (
+
+
+
+
+ ) : null
+}));
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+const initialState = {
+ emailTemplateState: {
+ entity: { id: 0, identifier: "" },
+ templateLoading: false,
+ clients: null,
+ preview: null,
+ json_data: {},
+ errors: {},
+ render_errors: []
+ }
+};
+
+describe("EditEmailTemplatePage", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ getEmailTemplate.mockReturnValue(() => Promise.resolve());
+ resetTemplateForm.mockReturnValue({ type: "RESET_TEMPLATE_FORM" });
+ saveEmailTemplate.mockReturnValue(() => Promise.resolve());
+ getAllClients.mockReturnValue(() => Promise.resolve());
+ });
+
+ it("shows a loading state and defers mounting the form until the fetch resolves", async () => {
+ let resolveFetch;
+ getEmailTemplate.mockReturnValue(
+ () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ })
+ );
+
+ renderWithRedux(
+ ,
+ { initialState }
+ );
+
+ expect(screen.getByText("emails.loading_template")).toBeInTheDocument();
+ expect(screen.queryByTestId("email-template-form")).not.toBeInTheDocument();
+
+ await act(async () => {
+ resolveFetch();
+ await flushPromises();
+ });
+
+ expect(
+ screen.queryByText("emails.loading_template")
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId("email-template-form")).toBeInTheDocument();
+ });
+
+ it("ignores a stale fetch when template_id changes before it resolves", async () => {
+ let resolveFirst;
+ let resolveSecond;
+ getEmailTemplate.mockImplementation((templateId) => () => {
+ if (templateId === "1") {
+ return new Promise((resolve) => {
+ resolveFirst = resolve;
+ });
+ }
+ return new Promise((resolve) => {
+ resolveSecond = resolve;
+ });
+ });
+
+ const { rerender } = renderWithRedux(
+ ,
+ { initialState }
+ );
+
+ rerender(
+
+ );
+
+ await act(async () => {
+ resolveFirst();
+ await flushPromises();
+ });
+
+ // must stay in the loading state -- the stale response must not flip entityReady
+ expect(screen.getByText("emails.loading_template")).toBeInTheDocument();
+ expect(screen.queryByTestId("email-template-form")).not.toBeInTheDocument();
+
+ await act(async () => {
+ resolveSecond();
+ await flushPromises();
+ });
+
+ expect(
+ screen.queryByText("emails.loading_template")
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId("email-template-form")).toBeInTheDocument();
+ });
+
+ it("resets the form and fetches clients when there is no template_id", () => {
+ renderWithRedux(
+ ,
+ { initialState }
+ );
+
+ expect(resetTemplateForm).toHaveBeenCalled();
+ expect(getEmailTemplate).not.toHaveBeenCalled();
+ expect(getAllClients).toHaveBeenCalled();
+ });
+
+ it("fetches the entity when a template_id is present", () => {
+ renderWithRedux(
+ ,
+ { initialState }
+ );
+
+ expect(getEmailTemplate).toHaveBeenCalledWith("42");
+ expect(resetTemplateForm).not.toHaveBeenCalled();
+ });
+
+ it("saves the entity submitted by the form", async () => {
+ renderWithRedux(
+ ,
+ { initialState }
+ );
+
+ const saveButton = await screen.findByRole("button", {
+ name: "general.save"
+ });
+
+ await act(async () => {
+ await userEvent.click(saveButton);
+ await flushPromises();
+ });
+
+ expect(saveEmailTemplate).toHaveBeenCalledWith({
+ identifier: "Edited Template"
+ });
+ });
+
+ it("opens the JSON dialog and applies an update", async () => {
+ renderWithRedux(
+ ,
+ { initialState }
+ );
+
+ const openJsonButton = await screen.findByRole("button", {
+ name: "open-json"
+ });
+ await userEvent.click(openJsonButton);
+ expect(
+ screen.getByTestId("email-template-json-dialog")
+ ).toBeInTheDocument();
+
+ updateTemplateJsonData.mockReturnValue(() => Promise.resolve());
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", { name: "json-update" })
+ );
+ await flushPromises();
+ });
+
+ expect(updateTemplateJsonData).toHaveBeenCalledWith({ foo: "bar" });
+ expect(
+ screen.queryByTestId("email-template-json-dialog")
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/src/pages/emails/__tests__/email-template-json-dialog.test.js b/src/pages/emails/__tests__/email-template-json-dialog.test.js
new file mode 100644
index 000000000..6a83bac85
--- /dev/null
+++ b/src/pages/emails/__tests__/email-template-json-dialog.test.js
@@ -0,0 +1,98 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom";
+import EmailTemplateJsonDialog from "../email-template-json-dialog";
+
+jest.mock("@uiw/react-codemirror", () => ({
+ __esModule: true,
+ default: ({ value, onChange }) => (
+