- {templateLoaded ? (
-
-
- {!previewOnly && (
-
+
+ const isTemplateInvalid = () => mjmlEditor && mjmlRenderError !== null;
+
+ useEffect(() => {
+ onValidityChange?.(isTemplateInvalid());
+ }, [mjmlEditor, mjmlRenderError]);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ submit: () => onSubmit(stateEntity)
+ }),
+ [stateEntity, onSubmit]
+ );
+
+ useEffect(() => {
+ handleResizeWindow();
+ window.addEventListener("resize", handleResizeWindow);
+ return () => {
+ window.removeEventListener("resize", handleResizeWindow);
+ };
+ });
+
+ const email_clients_ddl = clients
+ ? clients.map((cli) => ({ label: cli.name, value: cli.id }))
+ : [];
+ const versions_ddl = stateEntity.versions
+ ? [
+ { 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 (
+
- ) : (
-
Loading template...
- )}
-
-
-
-
- );
-};
+ ) : (
+
Loading template...
+ )}
+
+
+
+ );
+ }
+);
export default EmailTemplateForm;
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..b4b4040a9 100644
--- a/src/components/inputs/email-template-input.js
+++ b/src/components/inputs/email-template-input.js
@@ -9,89 +9,143 @@
* 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) => {
+ // "selectOption"/"reset" fire when the input text is set programmatically
+ // (a pick, or the controlled value syncing back in) -- searching again on
+ // those wastes a request. Only a real keystroke ("input") or clearing the
+ // field should touch the options list.
+ 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/__tests__/email-layout.test.js b/src/layouts/__tests__/email-layout.test.js
new file mode 100644
index 000000000..b460b8c34
--- /dev/null
+++ b/src/layouts/__tests__/email-layout.test.js
@@ -0,0 +1,110 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * 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 { screen } from "@testing-library/react";
+import { Router, Route } from "react-router-dom";
+import { createMemoryHistory } from "history";
+import { renderWithRedux } from "../../utils/test-utils";
+import EmailLayout from "../email-layout";
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (k) => k }
+}));
+
+jest.mock("react-breadcrumbs", () => ({
+ Breadcrumb: () => null
+}));
+
+jest.mock("../../pages/emails/email-template-list-page", () => ({
+ __esModule: true,
+ default: () =>
+}));
+
+// Expose the raw param (not a fallback display value) so a route match that
+// wrongly captures the literal segment "new" as :template_id is distinguishable
+// from a match on the dedicated, param-less creation route.
+jest.mock("../../pages/emails/edit-email-template-page", () => ({
+ __esModule: true,
+ default: ({ match }) => (
+
+ )
+}));
+
+jest.mock("../../pages/emails/email-log-list-page", () => ({
+ __esModule: true,
+ default: () =>
+}));
+
+// grants "emails" access so Restrict lets the layout render
+const loggedInState = {
+ loggedUserState: {
+ member: { groups: [{ code: "super-admins" }] }
+ }
+};
+
+const renderAt = (path) => {
+ const history = createMemoryHistory({ initialEntries: [path] });
+ return renderWithRedux(
+
+
+ ,
+ { initialState: loggedInState }
+ );
+};
+
+describe("EmailLayout routing", () => {
+ it("renders the list page at /templates", () => {
+ renderAt("/app/emails/templates");
+ expect(screen.getByTestId("list-page")).toBeInTheDocument();
+ });
+
+ it.each([
+ ["/app/emails/templates/new", "no trailing slash"],
+ [
+ "/app/emails/templates/new/",
+ "trailing slash — regression guard: must NOT fall through to :template_id=\"new\""
+ ]
+ ])("renders the editor in create mode at %s (%s)", (path) => {
+ renderAt(path);
+ // the dedicated creation route has no :template_id param at all
+ expect(screen.getByTestId("edit-page")).toHaveAttribute(
+ "data-template-id",
+ ""
+ );
+ });
+
+ it("renders the editor with the template_id param at /templates/:template_id", () => {
+ renderAt("/app/emails/templates/42");
+ expect(screen.getByTestId("edit-page")).toHaveAttribute(
+ "data-template-id",
+ "42"
+ );
+ });
+
+ it("does not render the editor for a nested/unknown path under :template_id", () => {
+ renderAt("/app/emails/templates/42/extra");
+ expect(screen.queryByTestId("edit-page")).not.toBeInTheDocument();
+ // falls through to the catch-all redirect -> list page
+ expect(screen.getByTestId("list-page")).toBeInTheDocument();
+ });
+
+ it("renders the log page at /log", () => {
+ renderAt("/app/emails/log");
+ expect(screen.getByTestId("log-page")).toBeInTheDocument();
+ });
+});
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..bdf65096f
--- /dev/null
+++ b/src/pages/emails/__tests__/edit-email-template-page.test.js
@@ -0,0 +1,251 @@
+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", () => {
+ const { forwardRef, useImperativeHandle } = require("react");
+ return {
+ __esModule: true,
+ default: forwardRef(({ onSubmit, onRender }, ref) => {
+ useImperativeHandle(ref, () => ({
+ submit: () => onSubmit({ identifier: "Edited Template" })
+ }));
+ return (
+
+
+
+ );
+ })
+ };
+});
+
+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 }
+ );
+
+ // navigate to a different template before the first fetch resolves
+ rerender(
+
+ );
+
+ // the stale request for template 1 resolves late
+ 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();
+
+ // the current request for template 2 resolves
+ 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("submits the form through the imperative ref when Save is clicked", 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__/edit-email-template-popup.test.js b/src/pages/emails/__tests__/edit-email-template-popup.test.js
new file mode 100644
index 000000000..17a092783
--- /dev/null
+++ b/src/pages/emails/__tests__/edit-email-template-popup.test.js
@@ -0,0 +1,167 @@
+import React from "react";
+import { render, screen, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom";
+import flushPromises from "flush-promises";
+import EditEmailTemplatePopup from "../edit-email-template-popup";
+
+jest.mock("../../../components/forms/email-template-form", () => {
+ const { forwardRef, useImperativeHandle } = require("react");
+ return {
+ __esModule: true,
+ default: forwardRef(({ onSubmit, onRender, templateJsonData }, ref) => {
+ useImperativeHandle(ref, () => ({
+ submit: () => onSubmit({ identifier: "Edited Template" })
+ }));
+ return (
+
+
+ {JSON.stringify(templateJsonData)}
+
+
+
+ );
+ })
+ };
+});
+
+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 baseProps = (overrides = {}) => ({
+ entity: { id: 0, identifier: "" },
+ templateLoading: false,
+ errors: {},
+ clients: [],
+ preview: null,
+ renderErrors: [],
+ templateJsonData: { foo: "bar" },
+ renderEmailTemplate: jest.fn(),
+ updateTemplateJsonData: jest.fn(() => Promise.resolve()),
+ onSave: jest.fn(() => Promise.resolve()),
+ onClose: jest.fn(),
+ ...overrides
+});
+
+describe("EditEmailTemplatePopup", () => {
+ it("submits the form through the imperative ref and closes on a successful save", async () => {
+ const onSave = jest.fn(() => Promise.resolve());
+ const onClose = jest.fn();
+ render(
);
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", { name: "general.save" })
+ );
+ await flushPromises();
+ });
+
+ expect(onSave).toHaveBeenCalledWith({ identifier: "Edited Template" });
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("keeps the dialog open and re-enables the Save button when the save rejects", async () => {
+ const onSave = jest.fn(() => Promise.reject(new Error("save failed")));
+ const onClose = jest.fn();
+ render(
);
+
+ const saveButton = screen.getByRole("button", { name: "general.save" });
+ await act(async () => {
+ await userEvent.click(saveButton);
+ await flushPromises();
+ });
+
+ expect(onClose).not.toHaveBeenCalled();
+ expect(saveButton).not.toBeDisabled();
+ });
+
+ it("disables both the Save button and the close icon while a save is in flight", async () => {
+ let resolveSave;
+ const onSave = jest.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveSave = resolve;
+ })
+ );
+ render(
);
+
+ const saveButton = screen.getByRole("button", { name: "general.save" });
+ await userEvent.click(saveButton);
+
+ // blocks a UI-level double-submit and closing mid-save
+ expect(saveButton).toBeDisabled();
+ expect(onSave).toHaveBeenCalledTimes(1);
+ const closeButton = screen
+ .getAllByRole("button")
+ .find((btn) => btn.querySelector("svg"));
+ expect(closeButton).toBeDisabled();
+
+ await act(async () => {
+ resolveSave();
+ await flushPromises();
+ });
+ });
+
+ it("closes immediately when not saving", async () => {
+ const onClose = jest.fn();
+ render(
);
+
+ const closeButtons = screen
+ .getAllByRole("button")
+ .filter((btn) => btn.querySelector("svg"));
+ await userEvent.click(closeButtons[0]);
+
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("opens the JSON dialog from the form and applies an update", async () => {
+ const updateTemplateJsonData = jest.fn(() => Promise.resolve());
+ render(
+
+ );
+
+ expect(
+ screen.queryByTestId("email-template-json-dialog")
+ ).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole("button", { name: "open-json" }));
+ expect(
+ screen.getByTestId("email-template-json-dialog")
+ ).toBeInTheDocument();
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", { name: "json-update" })
+ );
+ await flushPromises();
+ });
+
+ expect(updateTemplateJsonData).toHaveBeenCalledWith({ baz: 1 });
+ expect(
+ screen.queryByTestId("email-template-json-dialog")
+ ).not.toBeInTheDocument();
+ // the updated JSON data flows back into the form
+ expect(screen.getByTestId("json-data")).toHaveTextContent(
+ JSON.stringify({ baz: 1 })
+ );
+ });
+});
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 }) => (
+