-
- {templateLoaded ? (
-
-
- {!previewOnly && (
-
-
- {mjmlEditor ? (
- <>
-
-
-
{
- setMjmlEditor(false);
- }}
- className="btn btn-primary"
- value={T.translate("emails.display_html")}
- />
- >
- ) : (
- <>
-
-
-
{
- 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}
- />
-
- )}
- {currentVersionExternalLink && (
-
- )}
-
-
-
- )}
- {!codeOnly && (
-
-
-
- setMobileView(!mobileView)}
- className="btn btn-primary"
- value={
- mobileView
- ? T.translate("emails.display_desktop")
- : T.translate("emails.display_mobile")
- }
- />
-
+
+
+
+
+ {textField("identifier", "emails.name")}
+
+
+ {`${T.translate("emails.client")} *`}
+
+
+ setFieldValue("allowed_clients", ev.target.value)
+ }
+ />
+
+
+
+ {`${T.translate("emails.parent")} *`}
+
+ setFieldValue("parent", ev.target.value)}
+ />
+
+
+
+ {textField("from_email", "emails.from_email")}
+ {textField("subject", "emails.subject")}
+
+
+ {`${T.translate("emails.max_retries")} *`}
+
+
+
+
+
+
+
+
+
+
+
+ {showCodeEditor && (
+
+
+ setMjmlEditor(false)}
+ />
+
+ {entity.id > 0 && values.versions.length > 0 && (
+
)}
-
-
- {!previewOnly && (
-
- {mjmlEditor ? (
-
- handleCodeMirrorMJMLChange(value, viewUpdate)
- }
- height="960px"
- theme={sublimeInit({
- settings: {
- caret: "#c6c6c6",
- fontFamily: "monospace"
- }
- })}
- extensions={[
- html({
- autoCloseTags: true,
- matchClosingTags: true,
- selfClosingTags: true
- })
- ]}
- />
- ) : (
-
- handleCodeMirrorHTMLChange(value, viewUpdate)
- }
- height="960px"
- theme={sublimeInit({
- settings: {
- caret: "#c6c6c6",
- fontFamily: "monospace"
- }
- })}
- extensions={[
- html({
- autoCloseTags: true,
- matchClosingTags: true,
- selfClosingTags: true
- })
- ]}
- />
- )}
-
- )}
-
+ {T.translate("emails.preview_title")}
+
- {!codeOnly && (
-
-
- {renderErrors.length > 0 ? (
-
- There is an error trying to render the email template:
-
- {renderErrors.map((err) => (
- - {err}
- ))}
-
-
- ) : mjmlRenderError?.message ? (
-
- There is an error trying to render the email template:
-
{mjmlRenderError.message}
-
- ) : (
- previewLoaded && (
-
- )
- )}
-
- )}
+ {mobileView
+ ? T.translate("emails.display_desktop")
+ : T.translate("emails.display_mobile")}
+
-
- ) : (
-
Loading template...
- )}
-
-
-
-
+ )}
+
+
+
+ {showCodeEditor && (
+
+ )}
+ button": paneToggleButtonSx
+ }}
+ >
+ {showPreview && (
+ handleTabChange(ev)}
+ >
+
+
+ )}
+ {showCodeEditor && (
+ handleTabChange(ev)}
+ >
+
+
+ )}
+
+ {showPreview && (
+ iframe": {
+ border: "none",
+ display: "block",
+ margin: "0 auto",
+ transformOrigin: "top left"
+ }
+ }}
+ >
+ {templateLoading && (
+
+
+
+ )}
+ {renderPreviewBody()}
+
+ )}
+
+
+
+
+ {T.translate("general.save")}
+
+
+
+
);
};
diff --git a/src/components/forms/email-template.less b/src/components/forms/email-template.less
deleted file mode 100644
index cd0c924f3..000000000
--- a/src/components/forms/email-template.less
+++ /dev/null
@@ -1,105 +0,0 @@
-.email-template-container {
- height: 960px;
- margin-bottom: 50px;
- .email-template-buttons {
- display: flex;
- flex-direction: row;
-
- > div {
- width: 50%;
- &:first-of-type {
- display: flex;
- align-items: end;
- justify-content: space-between;
- margin-right: 30px;
- }
- &:last-of-type {
- margin-left: 30px;
- }
- }
- }
-
- .email-template-content {
- display: flex;
- flex-direction: row;
- height: 100%;
-
- .email-template-code {
- min-width: 50%;
- }
-
- .email-template-content-buttons {
- width: 30px;
- display: flex;
- &.single-button {
- width: 20px;
- }
-
- button {
- width: 100%;
- padding: 0;
- background-color: #42474e;
- border-right: 1px solid #343436;
- color: #fff;
- cursor: pointer;
- flex: 1;
- font-size: 12px;
- justify-content: center;
- transition: background-color 0.3s ease-in-out;
- &:hover {
- background-color: #535a63;
- }
- }
- }
-
- .email-template-preview {
- min-width: 50%;
- position: relative;
- > iframe {
- border: none;
- display: block;
- margin: 0 auto;
- transform-origin: top left;
- }
- }
- }
-}
-
-.email-history-ddl {
- a {
- color: inherit;
- }
-}
-
-@media only screen and (max-width: 992px) {
- .email-template-container {
- .email-template-buttons {
- > div {
- width: 100%;
- &:first-of-type {
- margin-left: 0;
- > div:last-of-type {
- width: 100%;
- }
- }
- }
- }
- }
-}
-
-@media only screen and (max-width: 600px) {
- .email-template-container {
- .email-template-buttons {
- > div {
- &:first-of-type {
- margin-left: 0;
- flex-direction: column;
- align-items: start;
- > div:last-of-type {
- padding: 0;
- }
- }
- }
- }
- }
-}
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..167a1cbbf
--- /dev/null
+++ b/src/components/inputs/__tests__/email-template-input.test.js
@@ -0,0 +1,208 @@
+import React from "react";
+import { render, screen, within, waitFor, act } 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()
+}));
+
+const mockTemplates = (templates) =>
+ queryTemplates.mockImplementation((input, callback) => callback(templates));
+
+describe("EmailTemplateInput", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it.each([
+ {
+ mode: "object",
+ plainValue: false,
+ selected: { id: "42", identifier: "welcome_email" },
+ cleared: { id: "", identifier: "" }
+ },
+ {
+ mode: "plainValue",
+ plainValue: true,
+ selected: "welcome_email",
+ cleared: ""
+ }
+ ])(
+ "emits the $mode shape on select and clear",
+ async ({ plainValue, selected, cleared }) => {
+ mockTemplates([{ id: 42, identifier: "welcome_email" }]);
+ const onChange = jest.fn();
+
+ const { rerender } = render(
+
+ );
+
+ await userEvent.type(screen.getByRole("combobox"), "welcome");
+ expect(queryTemplates).toHaveBeenLastCalledWith(
+ "welcome",
+ expect.any(Function)
+ );
+
+ const callsBeforeSelect = queryTemplates.mock.calls.length;
+ await userEvent.click(await screen.findByText("welcome_email"));
+
+ expect(onChange).toHaveBeenLastCalledWith({
+ target: { id: "tpl", value: selected, type: "emailtemplateinput" }
+ });
+ // selecting fills the input with the label, which must not re-search
+ expect(queryTemplates).toHaveBeenCalledTimes(callsBeforeSelect);
+
+ rerender(
+
+ );
+ await userEvent.click(screen.getByLabelText(/clear/i));
+
+ expect(onChange).toHaveBeenLastCalledWith({
+ target: { id: "tpl", value: cleared, type: "emailtemplateinput" }
+ });
+ }
+ );
+
+ it("excludes the owner template from the options", async () => {
+ mockTemplates([
+ { id: 1, identifier: "self" },
+ { id: 2, identifier: "other" }
+ ]);
+
+ render(
+
+ );
+
+ await userEvent.type(screen.getByRole("combobox"), "e");
+
+ const listbox = await screen.findByRole("listbox");
+ expect(within(listbox).queryByText("self")).not.toBeInTheDocument();
+ expect(within(listbox).getByText("other")).toBeInTheDocument();
+ });
+
+ it("does not duplicate the selected template in the options", async () => {
+ mockTemplates([
+ { id: 42, identifier: "welcome_email" },
+ { id: 43, identifier: "welcome_email_2" }
+ ]);
+
+ const Harness = () => {
+ const [value, setValue] = React.useState("");
+ return (
+
setValue(ev.target.value)}
+ plainValue
+ />
+ );
+ };
+ render();
+
+ const input = screen.getByRole("combobox");
+ await userEvent.type(input, "welcome");
+ await userEvent.click(
+ await screen.findByRole("option", { name: "welcome_email" })
+ );
+
+ // reopen the listbox with the value selected
+ await userEvent.click(input);
+ await userEvent.keyboard("{ArrowDown}");
+
+ const labels = (await screen.findAllByRole("option")).map(
+ (option) => option.textContent
+ );
+ expect(labels).toEqual(["welcome_email", "welcome_email_2"]);
+ });
+
+ it("clears the loading state when the search input is emptied", async () => {
+ queryTemplates.mockImplementation(() => {});
+ render( {}} />);
+
+ const input = screen.getByRole("combobox");
+ await userEvent.type(input, "w");
+ expect(screen.getByRole("progressbar")).toBeInTheDocument();
+
+ await userEvent.clear(input);
+ await waitFor(() =>
+ expect(screen.queryByRole("progressbar")).not.toBeInTheDocument()
+ );
+ });
+
+ it("ignores an older search response that lands after a newer one", async () => {
+ const callbacks = {};
+ queryTemplates.mockImplementation((input, callback) => {
+ callbacks[input] = callback;
+ });
+ render( {}} />);
+
+ const input = screen.getByRole("combobox");
+ await userEvent.type(input, "ab");
+
+ act(() => callbacks.ab([{ id: 2, identifier: "abc_newer" }]));
+ act(() => callbacks.a([{ id: 1, identifier: "a_older" }]));
+
+ expect(
+ await screen.findByRole("option", { name: "abc_newer" })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("option", { name: "a_older" })
+ ).not.toBeInTheDocument();
+ });
+
+ it.each([
+ {
+ mode: "object",
+ plainValue: false,
+ value: { id: 42, identifier: "welcome_email" }
+ },
+ { mode: "plainValue", plainValue: true, value: "welcome_email" }
+ ])(
+ "keeps the typed search when a template is already selected ($mode)",
+ async ({ plainValue, value }) => {
+ queryTemplates.mockImplementation((input, callback) => {
+ setTimeout(() => callback([{ id: 7, identifier: "newsletter" }]), 10);
+ });
+ render(
+ {}}
+ plainValue={plainValue}
+ />
+ );
+
+ const input = screen.getByRole("combobox");
+ await userEvent.click(input);
+ await userEvent.clear(input);
+ await userEvent.type(input, "news");
+
+ expect(
+ await screen.findByRole("option", { name: "newsletter" })
+ ).toBeInTheDocument();
+ expect(input).toHaveValue("news");
+ }
+ );
+});
diff --git a/src/components/inputs/email-template-input.js b/src/components/inputs/email-template-input.js
index d0be2e9eb..31eb7ddc2 100644
--- a/src/components/inputs/email-template-input.js
+++ b/src/components/inputs/email-template-input.js
@@ -11,87 +11,150 @@
* limitations under the License.
* */
-import React from "react";
-import AsyncSelect from "react-select/lib/Async";
+import React, { useMemo, useRef, 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);
-
- this.handleChange = this.handleChange.bind(this);
- this.getTemplates = this.getTemplates.bind(this);
- }
+const EmailTemplateInput = ({
+ id,
+ name,
+ value,
+ onChange,
+ ownerId,
+ placeholder,
+ error,
+ plainValue,
+ isClearable
+}) => {
+ const [options, setOptions] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const requestSeq = useRef(0);
+
+ const fetchOptions = (input) => {
+ requestSeq.current += 1;
+ const mySeq = requestSeq.current;
+ setLoading(true);
+ queryTemplates(input, (templates) => {
+ // a newer search (or a clear) superseded this one
+ if (mySeq !== requestSeq.current) return;
+ const filtered = ownerId
+ ? templates.filter((t) => t.id !== ownerId)
+ : templates;
+ const mappedOptions = filtered.map((t) => ({
+ value: plainValue ? t.identifier : t.id.toString(),
+ label: t.identifier
+ }));
+ setOptions(mappedOptions);
+ setLoading(false);
+ });
+ };
+
+ 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;
+
+ if (!input) {
+ requestSeq.current += 1;
+ setOptions([]);
+ setLoading(false);
+ 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" } });
+ };
+
+ // keep the reference stable between renders: MUI Autocomplete resets the
+ // typed text whenever the value reference changes
+ const selectedOption = useMemo(() => {
+ if (!value) return null;
+ return plainValue
+ ? { value, label: value }
+ : { value: String(value.id ?? ""), label: value.identifier ?? "" };
+ }, [value, plainValue]);
+
+ // the selected value may not be in the freshly-fetched options list --
+ // pin it in so Autocomplete doesn't warn about an "invalid" controlled value
+ const displayOptions =
+ selectedOption && !options.some((o) => o.value === selectedOption.value)
+ ? [selectedOption, ...options]
+ : options;
+
+ return (
+
+ option.value === selected.value
}
- };
-
- this.props.onChange(ev);
- }
-
- 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, name, 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, label: value }
- : { value: value.id.toString(), label: value.identifier };
- }
-
- return (
-
-
option.label || ""}
+ onChange={handleChange}
+ onInputChange={handleInputChange}
+ renderInput={(params) => (
+
+ {loading && }
+ {params.InputProps.endAdornment}
+ >
+ )
+ }
+ }}
/>
- {has_error && {error}
}
-
- );
- }
-}
+ )}
+ />
+ );
+};
+
+EmailTemplateInput.propTypes = {
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string,
+ 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,
+ isClearable: PropTypes.bool
+};
+
+EmailTemplateInput.defaultProps = {
+ name: undefined,
+ value: null,
+ ownerId: null,
+ placeholder: "",
+ error: "",
+ plainValue: false,
+ isClearable: false
+};
+
+export default EmailTemplateInput;
diff --git a/src/i18n/en.json b/src/i18n/en.json
index ea9c9f400..171a5e778 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -3354,6 +3354,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",
@@ -3368,6 +3369,12 @@
"display_preview": "Show Template Preview",
"html_content": "Html Content",
"mjml_content": "MJML Template",
+ "mjml_format": "MJML format",
+ "jinja_format": "jinja format",
+ "using": "using",
+ "in": "in",
+ "json": "JSON",
+ "format": "format",
"plain_content": "Plain Content",
"display_mobile": "View Mobile",
"display_desktop": "View Desktop",
@@ -3376,6 +3383,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",
@@ -3384,6 +3393,7 @@
"delete_template_warning": "Are you sure you want to delete template ",
"template_saved": "Template saved successfully.",
"template_created": "Template created successfully.",
+ "error_render_template": "There is an error trying to render the email template:",
"placeholders": {
"search_emails": "Search emails",
"search_templates": "Search templates by name",
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..91075f3fb
--- /dev/null
+++ b/src/pages/emails/__tests__/edit-email-template-page.test.js
@@ -0,0 +1,228 @@
+import React from "react";
+import { act, fireEvent, screen } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { MemoryRouter } from "react-router-dom";
+import flushPromises from "flush-promises";
+import { renderWithRedux } from "../../../utils/test-utils";
+import EditEmailTemplatePage from "../edit-email-template-page";
+import {
+ getEmailTemplate,
+ resetTemplateForm,
+ saveEmailTemplate,
+ getAllClients
+} 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 }) => (
+ onSubmit({ id: 42 }).catch(() => {})}
+ />
+ )
+}));
+
+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" });
+ getAllClients.mockReturnValue(() => Promise.resolve());
+ });
+
+ it("fetches the template and defers mounting the form until the fetch resolves", async () => {
+ let resolveFetch;
+ getEmailTemplate.mockReturnValue(
+ () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ })
+ );
+
+ renderWithRedux(
+ ,
+ { initialState }
+ );
+
+ expect(getEmailTemplate).toHaveBeenCalledWith("42");
+ expect(resetTemplateForm).not.toHaveBeenCalled();
+ 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 instead of fetching on the new-template route", () => {
+ renderWithRedux(
+ ,
+ { initialState }
+ );
+
+ expect(resetTemplateForm).toHaveBeenCalled();
+ expect(getEmailTemplate).not.toHaveBeenCalled();
+ });
+
+ it("does not mount the form with a stale entity when the template fetch fails", async () => {
+ getEmailTemplate.mockReturnValue(() =>
+ Promise.reject(Object.assign(new Error("Not Found"), { status: 404 }))
+ );
+
+ renderWithRedux(
+
+
+ ,
+ {
+ initialState: {
+ emailTemplateState: {
+ ...initialState.emailTemplateState,
+ // persisted from a previously viewed template
+ entity: { id: 7, identifier: "other-template" }
+ }
+ }
+ }
+ );
+
+ await act(async () => {
+ await flushPromises();
+ });
+
+ expect(getEmailTemplate).toHaveBeenCalledWith("5");
+ expect(screen.queryByTestId("email-template-form")).not.toBeInTheDocument();
+ });
+
+ it.each([
+ { outcome: "succeeds", result: () => Promise.resolve(), redirects: true },
+ {
+ outcome: "fails",
+ result: () => Promise.reject(new Error("412")),
+ redirects: false
+ }
+ ])(
+ "redirects to the list only when the save $outcome",
+ async ({ result, redirects }) => {
+ saveEmailTemplate.mockReturnValue(result);
+ const history = { push: jest.fn() };
+
+ renderWithRedux(
+ ,
+ { initialState }
+ );
+
+ await act(async () => {
+ await flushPromises();
+ });
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId("email-template-form"));
+ await flushPromises();
+ });
+
+ expect(saveEmailTemplate).toHaveBeenCalledWith({ id: 42 });
+ if (redirects) {
+ expect(history.push).toHaveBeenCalledWith("/app/emails/templates");
+ } else {
+ expect(history.push).not.toHaveBeenCalled();
+ }
+ }
+ );
+});
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..673016f84
--- /dev/null
+++ b/src/pages/emails/__tests__/email-template-json-dialog.test.js
@@ -0,0 +1,83 @@
+import React from "react";
+import { render, screen, fireEvent, waitFor } 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 }) => (
+