+
+ {showCodeEditor && (
+
- {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 && (
-
- )}
-
+
setMjmlEditor(false)}
+ />
+ {entity.id > 0 && stateEntity.versions.length > 0 && (
+
+ )}
)}
- {!codeOnly && (
-
+ {showPreview && (
+
- 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")}
+
)}
- {!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
- })
- ]}
- />
- )}
-
+ {showCodeEditor && (
+
)}
- {!codeOnly && (
+ {showPreview && (
)}
- {!previewOnly && (
+ {showCodeEditor && (
- {!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 && (
-
- )
+ {showPreview && (
+
+ {templateLoading && (
+
+
+
)}
+ {renderPreviewBody()}
)}
@@ -636,21 +682,17 @@ const EmailTemplateForm = ({
) : (
Loading template...
)}
-
-
-
+
+
+
+
+
);
};
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..2c2f2cc30
--- /dev/null
+++ b/src/components/inputs/__tests__/email-template-input.test.js
@@ -0,0 +1,144 @@
+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..0d7bf28b2 100644
--- a/src/components/inputs/email-template-input.js
+++ b/src/components/inputs/email-template-input.js
@@ -9,89 +9,156 @@
* 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, 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);
+const EmailTemplateInput = ({
+ id,
+ value,
+ onChange,
+ ownerId,
+ placeholder,
+ error,
+ plainValue,
+ defaultOptions,
+ isClearable,
+ cacheOptions
+}) => {
+ const [options, setOptions] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const optionsCacheRef = useRef(new Map());
+
+ const fetchOptions = (input) => {
+ if (cacheOptions && optionsCacheRef.current.has(input)) {
+ setOptions(optionsCacheRef.current.get(input));
+ return;
+ }
- this.handleChange = this.handleChange.bind(this);
- this.getTemplates = this.getTemplates.bind(this);
- }
+ setLoading(true);
+ queryTemplates(input, (templates) => {
+ const filtered = ownerId
+ ? templates.filter((t) => t.id !== ownerId)
+ : templates;
+ const mappedOptions = filtered.map((t) => ({
+ value: t.id.toString(),
+ label: t.identifier
+ }));
+ if (cacheOptions) optionsCacheRef.current.set(input, mappedOptions);
+ setOptions(mappedOptions);
+ 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;
+
+ 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,
+ isClearable: PropTypes.bool,
+ cacheOptions: PropTypes.bool
+};
+
+EmailTemplateInput.defaultProps = {
+ value: null,
+ ownerId: null,
+ placeholder: "",
+ error: "",
+ plainValue: false,
+ defaultOptions: false,
+ isClearable: false,
+ cacheOptions: false
+};
+
+export default EmailTemplateInput;
diff --git a/src/i18n/en.json b/src/i18n/en.json
index c3c0e36b2..9bd5c0753 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",
@@ -3315,6 +3318,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..f3bcee8db
--- /dev/null
+++ b/src/pages/emails/__tests__/edit-email-template-page.test.js
@@ -0,0 +1,245 @@
+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: ({ onUpdate, onClose }) => (
+
+
+
+
+ )
+}));
+
+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..06e285e2c
--- /dev/null
+++ b/src/pages/emails/__tests__/email-template-json-dialog.test.js
@@ -0,0 +1,81 @@
+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 }) => (
+