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 (
-
-
+ + - -
-
+ + - -
-
+ + -
-
-
-
+ + + + - -
-
+ + - -
-
+ + - -
-
-
-
- -
-
-
-
+ + + + + + + + + {templateLoaded ? (

- { setMjmlEditor(false); }} - className="btn btn-primary" - value={T.translate("emails.display_html")} - /> + > + {T.translate("emails.display_html")} + ) : ( <> @@ -452,46 +498,35 @@ const EmailTemplateForm = ({ {" *"}
- { - setMjmlEditor(true); - }} - className="btn btn-primary" - value={T.translate("emails.display_mjml")} - /> + )}
-
-
- {entity.id > 0 && stateEntity.versions.length > 0 && ( -
- -
- ({ - ...baseStyles, - color: state.isSelected ? "white" : "inherit" - }) - }} - className="email-history-ddl" - onChange={handleVersionChange} - /> -
- )} + {entity.id > 0 && stateEntity.versions.length > 0 && ( + + + +
+ +
{currentVersionExternalLink && ( -
+ -
+
)} -
-
+ + )}
)} {!codeOnly && (

- setMobileView(!mobileView)} - className="btn btn-primary" - value={ - mobileView - ? T.translate("emails.display_desktop") - : T.translate("emails.display_mobile") - } - /> + > + {mobileView + ? T.translate("emails.display_desktop") + : T.translate("emails.display_mobile")} +
)}
@@ -602,21 +635,35 @@ const EmailTemplateForm = ({
{!codeOnly && (
- + {templateLoading && ( + + + + )} {renderErrors.length > 0 ? ( -
+ There is an error trying to render the email template:
    {renderErrors.map((err) => ( -
  • {err}
  • +
  • {err}
  • ))}
-
+ ) : mjmlRenderError?.message ? ( -
+ There is an error trying to render the email template: -
    {mjmlRenderError.message}
-
+
    +
  • {mjmlRenderError.message}
  • +
+ ) : ( previewLoaded && (