From 16d139b7408a21229dede83dba518cbcec20d459 Mon Sep 17 00:00:00 2001
From: Santiago Palenque
Date: Fri, 11 Sep 2026 15:29:42 -0300
Subject: [PATCH 1/6] chore: migrate marketing page to MUI
---
.../marketing-setting-list-page.test.js | 249 +++++++++++++
.../marketing/marketing-setting-list-page.js | 335 ++++++++----------
src/styles/table.less | 16 -
3 files changed, 406 insertions(+), 194 deletions(-)
create mode 100644 src/pages/marketing/__tests__/marketing-setting-list-page.test.js
delete mode 100644 src/styles/table.less
diff --git a/src/pages/marketing/__tests__/marketing-setting-list-page.test.js b/src/pages/marketing/__tests__/marketing-setting-list-page.test.js
new file mode 100644
index 000000000..be9f6113a
--- /dev/null
+++ b/src/pages/marketing/__tests__/marketing-setting-list-page.test.js
@@ -0,0 +1,249 @@
+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, createMockSummit } from "../../../utils/test-utils";
+import MarketingSettingListPage from "../marketing-setting-list-page";
+import {
+ getMarketingSettings,
+ deleteSetting,
+ cloneMarketingSettings
+} from "../../../actions/marketing-actions";
+import showConfirmDialog from "../../../components/mui/showConfirmDialog";
+import { DEFAULT_CURRENT_PAGE } from "../../../utils/constants";
+
+jest.mock("../../../actions/marketing-actions", () => ({
+ getMarketingSettings: jest.fn(),
+ deleteSetting: jest.fn(),
+ cloneMarketingSettings: jest.fn()
+}));
+
+jest.mock("../../../components/mui/showConfirmDialog", () => jest.fn());
+
+jest.mock("../../../components/summit-dropdown", () => ({
+ __esModule: true,
+ default: ({ onClick }) => (
+
+ )
+}));
+
+jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({
+ __esModule: true,
+ default: ({ onEdit, onDelete, onSort, onPageChange, onPerPageChange }) => (
+
+
+
+
+
+
+
+ )
+}));
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/search-input",
+ () => ({
+ __esModule: true,
+ default: ({ onSearch }) => (
+
+ )
+ })
+);
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+const mockHistory = { push: jest.fn() };
+
+const initialState = {
+ currentSummitState: { currentSummit: createMockSummit() },
+ marketingSettingListState: {
+ settings: [
+ {
+ id: 1,
+ key: "test-key",
+ type: "TEXT",
+ value: "test-value",
+ selection_plan_id: "N/A"
+ }
+ ],
+ totalSettings: 1,
+ perPage: 10,
+ currentPage: 1,
+ term: "",
+ order: "id",
+ orderDir: 1,
+ lastPage: 1
+ }
+};
+
+describe("MarketingSettingListPage", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ getMarketingSettings.mockReturnValue(() => Promise.resolve());
+ deleteSetting.mockReturnValue(() => Promise.resolve());
+ cloneMarketingSettings.mockReturnValue(() => Promise.resolve());
+ showConfirmDialog.mockResolvedValue(true);
+ });
+
+ it("deletes the setting when the confirm dialog resolves true", async () => {
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(screen.getByRole("button", { name: "delete-row" }));
+ await flushPromises();
+ });
+
+ expect(showConfirmDialog).toHaveBeenCalled();
+ expect(deleteSetting).toHaveBeenCalledWith(1);
+ });
+
+ it("does not delete the setting when the confirm dialog resolves false", async () => {
+ showConfirmDialog.mockResolvedValue(false);
+
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(screen.getByRole("button", { name: "delete-row" }));
+ await flushPromises();
+ });
+
+ expect(deleteSetting).not.toHaveBeenCalled();
+ });
+
+ it("clones settings when the confirm dialog resolves true", async () => {
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", { name: "clone-trigger" })
+ );
+ await flushPromises();
+ });
+
+ expect(cloneMarketingSettings).toHaveBeenCalledWith(999);
+ });
+
+ it("does not clone settings when the confirm dialog resolves false", async () => {
+ showConfirmDialog.mockResolvedValue(false);
+
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", { name: "clone-trigger" })
+ );
+ await flushPromises();
+ });
+
+ expect(cloneMarketingSettings).not.toHaveBeenCalled();
+ });
+
+ it("resets to the first page on search", async () => {
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", { name: "search-trigger" })
+ );
+ });
+
+ expect(getMarketingSettings).toHaveBeenLastCalledWith(
+ "newterm",
+ DEFAULT_CURRENT_PAGE,
+ 10,
+ "id",
+ 1
+ );
+ });
+
+ it("resets to the first page on per-page change", async () => {
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(screen.getByRole("button", { name: "perpage-50" }));
+ });
+
+ expect(getMarketingSettings).toHaveBeenLastCalledWith(
+ "",
+ DEFAULT_CURRENT_PAGE,
+ 50,
+ "id",
+ 1
+ );
+ });
+
+ it("keeps the current page on sort", async () => {
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(screen.getByRole("button", { name: "sort-col" }));
+ });
+
+ expect(getMarketingSettings).toHaveBeenLastCalledWith("", 1, 10, "key", -1);
+ });
+
+ it("navigates to the edit page", async () => {
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(screen.getByRole("button", { name: "edit-row" }));
+ });
+
+ expect(mockHistory.push).toHaveBeenCalledWith(
+ "/app/summits/456/marketing/1"
+ );
+ });
+
+ it("navigates to the add-setting page", async () => {
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", { name: "marketing.add_setting" })
+ );
+ });
+
+ expect(mockHistory.push).toHaveBeenCalledWith(
+ "/app/summits/456/marketing/new"
+ );
+ });
+});
diff --git a/src/pages/marketing/marketing-setting-list-page.js b/src/pages/marketing/marketing-setting-list-page.js
index c18f720b4..214c052ef 100644
--- a/src/pages/marketing/marketing-setting-list-page.js
+++ b/src/pages/marketing/marketing-setting-list-page.js
@@ -11,208 +11,187 @@
* limitations under the License.
* */
-import React from "react";
+import React, { useEffect } from "react";
import { connect } from "react-redux";
import T from "i18n-react/dist/i18n-react";
-import Swal from "sweetalert2";
-import { Pagination } from "react-bootstrap";
-import FreeTextSearch from "openstack-uicore-foundation/lib/components/free-text-search"
-import Table from "openstack-uicore-foundation/lib/components/table";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import AddIcon from "@mui/icons-material/Add";
+import MuiTable from "openstack-uicore-foundation/lib/components/mui/table";
+import GridToolbar from "../../components/mui/grid-toolbar";
import SummitDropdown from "../../components/summit-dropdown";
+import showConfirmDialog from "../../components/mui/showConfirmDialog";
import { getSummitById } from "../../actions/summit-actions";
import {
getMarketingSettings,
deleteSetting,
cloneMarketingSettings
} from "../../actions/marketing-actions";
+import { DEFAULT_CURRENT_PAGE } from "../../utils/constants";
+
+const wrapLongText = (value) => (
+
+ {value}
+
+);
+
+const MarketingSettingListPage = ({
+ currentSummit,
+ settings,
+ currentPage,
+ perPage,
+ term,
+ order,
+ orderDir,
+ totalSettings,
+ history,
+ getMarketingSettings,
+ deleteSetting,
+ cloneMarketingSettings
+}) => {
+ useEffect(() => {
+ if (currentSummit) {
+ getMarketingSettings(term, currentPage, perPage, order, orderDir);
+ }
+ }, [currentSummit]);
-import "../../styles/table.less";
+ const handleEdit = (row) => {
+ history.push(`/app/summits/${currentSummit.id}/marketing/${row.id}`);
+ };
-class MarketingSettingListPage extends React.Component {
- constructor(props) {
- super(props);
+ const handlePageChange = (page) => {
+ getMarketingSettings(term, page, perPage, order, orderDir);
+ };
- this.handleEdit = this.handleEdit.bind(this);
- this.handlePageChange = this.handlePageChange.bind(this);
- this.handleSort = this.handleSort.bind(this);
- this.handleSearch = this.handleSearch.bind(this);
- this.handleNewSetting = this.handleNewSetting.bind(this);
- this.handleDeleteSetting = this.handleDeleteSetting.bind(this);
- this.handleCloneSettings = this.handleCloneSettings.bind(this);
+ const handlePerPageChange = (newPerPage) => {
+ getMarketingSettings(
+ term,
+ DEFAULT_CURRENT_PAGE,
+ newPerPage,
+ order,
+ orderDir
+ );
+ };
- this.state = {};
- }
+ const handleSort = (key, dir) => {
+ getMarketingSettings(term, currentPage, perPage, key, dir);
+ };
- componentDidMount() {
- const { currentSummit } = this.props;
- if (currentSummit) {
- this.props.getMarketingSettings();
- }
- }
-
- handleEdit(setting_id) {
- const { currentSummit, history } = this.props;
- history.push(`/app/summits/${currentSummit.id}/marketing/${setting_id}`);
- }
-
- handlePageChange(page) {
- const { term, order, orderDir, perPage } = this.props;
- this.props.getMarketingSettings(term, page, perPage, order, orderDir);
- }
-
- handleSort(index, key, dir, func) {
- const { term, page, perPage } = this.props;
- this.props.getMarketingSettings(term, page, perPage, key, dir);
- }
-
- handleSearch(term) {
- const { order, orderDir, page, perPage } = this.props;
- this.props.getMarketingSettings(term, page, perPage, order, orderDir);
- }
-
- handleNewSetting(ev) {
- const { currentSummit, history } = this.props;
- history.push(`/app/summits/${currentSummit.id}/marketing/new`);
- }
+ const handleSearch = (newTerm) => {
+ getMarketingSettings(
+ newTerm,
+ DEFAULT_CURRENT_PAGE,
+ perPage,
+ order,
+ orderDir
+ );
+ };
- handleDeleteSetting(settingId) {
- const { deleteSetting, settings } = this.props;
- const setting = settings.find((s) => s.id === settingId);
+ const handleNewSetting = (ev) => {
+ ev.preventDefault();
+ history.push(`/app/summits/${currentSummit.id}/marketing/new`);
+ };
- Swal.fire({
+ const handleDeleteSetting = async (row) => {
+ const confirmed = await showConfirmDialog({
title: T.translate("general.are_you_sure"),
- text: `${T.translate("marketing.delete_setting_warning")} ${setting.key}`,
- type: "warning",
- showCancelButton: true,
- confirmButtonColor: "#DD6B55",
- confirmButtonText: T.translate("general.yes_delete")
- }).then((result) => {
- if (result.value) {
- deleteSetting(settingId);
- }
+ text: `${T.translate("marketing.delete_setting_warning")} ${row.key}`,
+ iconType: "warning",
+ confirmButtonText: T.translate("general.yes_delete"),
+ confirmButtonColor: "error"
});
- }
- handleCloneSettings(summitId) {
- const { cloneMarketingSettings } = this.props;
+ if (confirmed) deleteSetting(row.id);
+ };
- Swal.fire({
+ const handleCloneSettings = async (summitId) => {
+ const confirmed = await showConfirmDialog({
title: T.translate("general.are_you_sure"),
text: T.translate("marketing.clone_settings_warning"),
- type: "warning",
- showCancelButton: true,
- confirmButtonColor: "#DD6B55",
- confirmButtonText: T.translate("marketing.yes_clone")
- }).then((result) => {
- if (result.value) {
- cloneMarketingSettings(summitId);
- }
+ iconType: "warning",
+ confirmButtonText: T.translate("marketing.yes_clone"),
+ confirmButtonColor: "error"
});
- }
-
- render() {
- const {
- currentSummit,
- settings,
- lastPage,
- currentPage,
- term,
- order,
- orderDir,
- totalSettings
- } = this.props;
-
- const columns = [
- { columnKey: "id", value: T.translate("general.id"), sortable: true },
- { columnKey: "key", value: T.translate("marketing.key"), sortable: true },
- { columnKey: "type", value: T.translate("marketing.type") },
- {
- columnKey: "value",
- value: T.translate("marketing.value"),
- title: true
- },
- {
- columnKey: "selection_plan_id",
- value: T.translate("marketing.selection_plan"),
- title: true
- }
- ];
-
- const table_options = {
- sortCol: order,
- sortDir: orderDir,
- className: "marketing-table",
- actions: {
- edit: { onClick: this.handleEdit },
- delete: { onClick: this.handleDeleteSetting }
- }
- };
-
- if (!currentSummit.id) return ;
-
- return (
-
-
- {" "}
- {T.translate("marketing.setting_list")} ({totalSettings})
-
-
-
-
-
-
-
-
-
-
-
-
- {settings.length === 0 && (
-
{T.translate("marketing.no_settings")}
- )}
-
- {settings.length > 0 && (
-
- )}
-
- );
- }
-}
+ if (confirmed) cloneMarketingSettings(summitId);
+ };
+
+ const columns = [
+ { columnKey: "id", header: T.translate("general.id"), sortable: true },
+ {
+ columnKey: "key",
+ header: T.translate("marketing.key"),
+ sortable: true
+ },
+ { columnKey: "type", header: T.translate("marketing.type") },
+ {
+ columnKey: "value",
+ header: T.translate("marketing.value"),
+ render: (row) => wrapLongText(row.value)
+ },
+ {
+ columnKey: "selection_plan_id",
+ header: T.translate("marketing.selection_plan"),
+ render: (row) => wrapLongText(row.selection_plan_id)
+ }
+ ];
+
+ const tableOptions = { sortCol: order, sortDir: orderDir };
+
+ if (!currentSummit.id) return ;
+
+ return (
+
+
+ {" "}
+ {T.translate("marketing.setting_list")} ({totalSettings})
+
+
+ }
+ >
+ {T.translate("marketing.add_setting")}
+
+
+
+
+
+
+ {settings.length === 0 && (
+
{T.translate("marketing.no_settings")}
+ )}
+
+ {settings.length > 0 && (
+
+
+
+ )}
+
+ );
+};
const mapStateToProps = ({
currentSummitState,
diff --git a/src/styles/table.less b/src/styles/table.less
deleted file mode 100644
index f12c5c459..000000000
--- a/src/styles/table.less
+++ /dev/null
@@ -1,16 +0,0 @@
-.marketing-table {
- td {
- max-width: 700px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-
- @media (min-width: 768px) {
- max-width: 450px;
- }
-
- @media only screen and (max-width: 768px) {
- max-width: 150px;
- }
- }
-}
From df1edf10ab97a201a50a03790c9b7e7ccc4b70e1 Mon Sep 17 00:00:00 2001
From: Santiago Palenque
Date: Fri, 11 Sep 2026 16:28:30 -0300
Subject: [PATCH 2/6] chore: pr review
---
.../marketing-setting-list-page.test.js | 82 +++++++++++--------
.../marketing/marketing-setting-list-page.js | 20 ++---
2 files changed, 55 insertions(+), 47 deletions(-)
diff --git a/src/pages/marketing/__tests__/marketing-setting-list-page.test.js b/src/pages/marketing/__tests__/marketing-setting-list-page.test.js
index be9f6113a..44bfcff11 100644
--- a/src/pages/marketing/__tests__/marketing-setting-list-page.test.js
+++ b/src/pages/marketing/__tests__/marketing-setting-list-page.test.js
@@ -1,5 +1,5 @@
import React from "react";
-import { act, screen } from "@testing-library/react";
+import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import flushPromises from "flush-promises";
@@ -30,30 +30,42 @@ jest.mock("../../../components/summit-dropdown", () => ({
)
}));
+let capturedColumns;
+
jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({
__esModule: true,
- default: ({ onEdit, onDelete, onSort, onPageChange, onPerPageChange }) => (
-
-
-
-
-
-
-
- )
+ default: ({
+ onEdit,
+ onDelete,
+ onSort,
+ onPageChange,
+ onPerPageChange,
+ columns
+ }) => {
+ capturedColumns = columns;
+ return (
+
+
+
+
+
+
+
+ );
+ }
}));
jest.mock(
@@ -106,23 +118,25 @@ describe("MarketingSettingListPage", () => {
showConfirmDialog.mockResolvedValue(true);
});
- it("deletes the setting when the confirm dialog resolves true", async () => {
+ it("bounds the value column width and wraps long values instead of overflowing", () => {
renderWithRedux(, {
initialState
});
- await act(async () => {
- await userEvent.click(screen.getByRole("button", { name: "delete-row" }));
- await flushPromises();
- });
+ const valueColumn = capturedColumns.find((c) => c.columnKey === "value");
+ expect(valueColumn.width).toBe(450);
- expect(showConfirmDialog).toHaveBeenCalled();
- expect(deleteSetting).toHaveBeenCalledWith(1);
- });
+ const longValue = "lorem ipsum ".repeat(50);
+ const { container } = render(valueColumn.render({ value: longValue }));
- it("does not delete the setting when the confirm dialog resolves false", async () => {
- showConfirmDialog.mockResolvedValue(false);
+ expect(container.firstChild).toHaveStyle({
+ wordBreak: "break-word",
+ overflowWrap: "anywhere"
+ });
+ expect(container).toHaveTextContent(longValue);
+ });
+ it("deletes the setting by id (confirm is handled inside MuiTable)", async () => {
renderWithRedux(, {
initialState
});
@@ -132,7 +146,7 @@ describe("MarketingSettingListPage", () => {
await flushPromises();
});
- expect(deleteSetting).not.toHaveBeenCalled();
+ expect(deleteSetting).toHaveBeenCalledWith(1);
});
it("clones settings when the confirm dialog resolves true", async () => {
diff --git a/src/pages/marketing/marketing-setting-list-page.js b/src/pages/marketing/marketing-setting-list-page.js
index 214c052ef..e3d622255 100644
--- a/src/pages/marketing/marketing-setting-list-page.js
+++ b/src/pages/marketing/marketing-setting-list-page.js
@@ -92,18 +92,6 @@ const MarketingSettingListPage = ({
history.push(`/app/summits/${currentSummit.id}/marketing/new`);
};
- const handleDeleteSetting = async (row) => {
- const confirmed = await showConfirmDialog({
- title: T.translate("general.are_you_sure"),
- text: `${T.translate("marketing.delete_setting_warning")} ${row.key}`,
- iconType: "warning",
- confirmButtonText: T.translate("general.yes_delete"),
- confirmButtonColor: "error"
- });
-
- if (confirmed) deleteSetting(row.id);
- };
-
const handleCloneSettings = async (summitId) => {
const confirmed = await showConfirmDialog({
title: T.translate("general.are_you_sure"),
@@ -127,6 +115,7 @@ const MarketingSettingListPage = ({
{
columnKey: "value",
header: T.translate("marketing.value"),
+ width: 450,
render: (row) => wrapLongText(row.value)
},
{
@@ -185,7 +174,12 @@ const MarketingSettingListPage = ({
onPerPageChange={handlePerPageChange}
onSort={handleSort}
onEdit={handleEdit}
- onDelete={handleDeleteSetting}
+ onDelete={deleteSetting}
+ getName={(row) => row.key}
+ deleteDialogBody={(name) =>
+ `${T.translate("marketing.delete_setting_warning")} ${name}`
+ }
+ confirmButtonColor="error"
/>
)}
From 0f72bec30dfeaab4c49f988988d08a6a2d630fbf Mon Sep 17 00:00:00 2001
From: Santiago Palenque
Date: Fri, 11 Sep 2026 16:29:25 -0300
Subject: [PATCH 3/6] chore: fix tests
---
.../marketing/__tests__/marketing-setting-list-page.test.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/pages/marketing/__tests__/marketing-setting-list-page.test.js b/src/pages/marketing/__tests__/marketing-setting-list-page.test.js
index 44bfcff11..d14a499fb 100644
--- a/src/pages/marketing/__tests__/marketing-setting-list-page.test.js
+++ b/src/pages/marketing/__tests__/marketing-setting-list-page.test.js
@@ -133,7 +133,7 @@ describe("MarketingSettingListPage", () => {
wordBreak: "break-word",
overflowWrap: "anywhere"
});
- expect(container).toHaveTextContent(longValue);
+ expect(container).toHaveTextContent(longValue.trim());
});
it("deletes the setting by id (confirm is handled inside MuiTable)", async () => {
From e3d461e5ad960169cdb865a4d1ffe0182be1b5ef Mon Sep 17 00:00:00 2001
From: Santiago Palenque
Date: Mon, 14 Sep 2026 19:22:10 -0300
Subject: [PATCH 4/6] chore: migrate edit form
---
src/actions/marketing-actions.js | 9 +-
.../__tests__/marketing-setting-form.test.js | 314 +++++++++++++++
.../forms/marketing-setting-form.js | 359 +++++++-----------
.../marketing-setting-list-page.test.js | 73 +---
.../marketing/edit-marketing-setting-page.js | 166 +++++---
.../marketing/marketing-setting-list-page.js | 5 +-
.../marketing-setting-list-reducer.test.js | 74 ++++
.../marketing-setting-list-reducer.js | 20 +-
8 files changed, 674 insertions(+), 346 deletions(-)
create mode 100644 src/components/forms/__tests__/marketing-setting-form.test.js
create mode 100644 src/reducers/marketing/__tests__/marketing-setting-list-reducer.test.js
diff --git a/src/actions/marketing-actions.js b/src/actions/marketing-actions.js
index 305e83802..ec8aedee3 100644
--- a/src/actions/marketing-actions.js
+++ b/src/actions/marketing-actions.js
@@ -20,9 +20,9 @@ import {
authErrorHandler,
postFile,
putFile,
- putRequest
+ putRequest,
+ setSnackbarMessage
} from "openstack-uicore-foundation/lib/utils/actions";
-import Swal from "sweetalert2";
import { getAccessTokenSafely, isHexColorSetting } from "../utils/methods";
import {
DEFAULT_PER_PAGE,
@@ -69,6 +69,7 @@ export const getMarketingSettings =
};
if (term) {
+ // TODO: key__contains is case sensitive
params.key__contains = term;
}
@@ -83,7 +84,7 @@ export const getMarketingSettings =
createAction(RECEIVE_SETTINGS),
`${window.MARKETING_API_BASE_URL}/api/public/v1/config-values/all/shows/${currentSummit.id}`,
authErrorHandler,
- { order, orderDir, term }
+ { order, orderDir, term, currentPage: page, perPage }
)(params)(dispatch).then(() => {
dispatch(stopLoading());
});
@@ -359,7 +360,7 @@ export const customErrorHandler = (err, res) => (dispatch) => {
}
}
- Swal.fire("Validation error", msg, "warning");
+ dispatch(setSnackbarMessage({ html: msg, type: "warning" }));
if (err.response.body.errors) {
dispatch({
diff --git a/src/components/forms/__tests__/marketing-setting-form.test.js b/src/components/forms/__tests__/marketing-setting-form.test.js
new file mode 100644
index 000000000..b5a49c5bd
--- /dev/null
+++ b/src/components/forms/__tests__/marketing-setting-form.test.js
@@ -0,0 +1,314 @@
+// ---- Mocks must come first ----
+
+// jsdom does not implement scrollIntoView; polyfill so the errors effect
+// (which calls scrollToError -> firstNode.scrollIntoView) does not throw.
+window.HTMLElement.prototype.scrollIntoView = jest.fn();
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/inputs/upload-input",
+ () => ({
+ __esModule: true,
+ default: ({ value, handleUpload, handleRemove }) => (
+
+
+ {value && (
+
+ )}
+
+ )
+ })
+);
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/inputs/editor-input-v3",
+ () => ({
+ __esModule: true,
+ default: ({ id, value, onChange, error }) => (
+
+
+ onChange({
+ target: { id, value: ev.target.value, type: "texteditor" }
+ })
+ }
+ />
+ {error &&
{error}
}
+
+ )
+ })
+);
+
+// Matches company-form.test.js's convention: mock the underlying
+// mui-color-input package, not our MuiFormikColorField wrapper, so the
+// wrapper's own commit-on-blur/Enter logic stays under test.
+jest.mock("mui-color-input", () => ({
+ MuiColorInput: ({ value, onChange, onBlur, name }) => (
+ onChange(ev.target.value)}
+ onBlur={(ev) => onBlur({ target: { name, value: ev.target.value } })}
+ />
+ )
+}));
+
+// The vendor Formik-input wrappers (openstack-uicore-foundation) own their own
+// error/FormHelperText rendering and have their own tests; here we only need
+// them to read/write real Formik state, mirroring the mocking convention used
+// elsewhere in this repo for these vendor components.
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield",
+ () => {
+ const React = require("react");
+ const { useField } = require("formik");
+ return {
+ __esModule: true,
+ default: function MockMuiFormikTextField({ name }) {
+ const [field] = useField(name);
+ return ;
+ }
+ };
+ }
+);
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/select",
+ () => {
+ const React = require("react");
+ const { useField } = require("formik");
+ return {
+ __esModule: true,
+ default: function MockMuiFormikSelect({ name, disabled, children }) {
+ const [field] = useField(name);
+ return (
+
+ );
+ }
+ };
+ }
+);
+
+// ---- Now imports ----
+/* eslint-disable import/first */
+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 { FormikProvider, useFormik } from "formik";
+import MarketingSettingForm from "../marketing-setting-form";
+import {
+ buildValues,
+ validationSchema
+} from "../../../pages/marketing/edit-marketing-setting-page";
+/* eslint-enable import/first */
+
+const TEXT_ENTITY = {
+ id: 0,
+ key: "SOME_KEY",
+ type: "TEXT",
+ value: "hello",
+ file_preview: "",
+ file: null,
+ selection_plan_id: ""
+};
+
+// Mirrors the formik wiring edit-marketing-setting-page.js provides in
+// production, so MarketingSettingForm's useFormikContext() has a real
+// context to read from.
+const Harness = ({
+ entity,
+ onSubmit = jest.fn(),
+ onDeleteImage = jest.fn(() => Promise.resolve())
+}) => {
+ const formik = useFormik({
+ initialValues: buildValues(entity),
+ validationSchema,
+ onSubmit
+ });
+
+ return (
+
+
+
+ {JSON.stringify(formik.values)}
+ {JSON.stringify(formik.errors)}
+
+ );
+};
+
+const readFormikValues = () =>
+ JSON.parse(screen.getByTestId("debug-values").textContent);
+const readFormikErrors = () =>
+ JSON.parse(screen.getByTestId("debug-errors").textContent);
+
+const clickSave = () =>
+ userEvent.click(screen.getByRole("button", { name: "general.save" }));
+
+describe("MarketingSettingForm", () => {
+ it("disables the type select once an existing setting is being edited", () => {
+ render();
+
+ expect(screen.getByTestId("select-type")).toBeDisabled();
+ });
+
+ it("leaves the type select enabled for a new setting", () => {
+ render();
+
+ expect(screen.getByTestId("select-type")).not.toBeDisabled();
+ });
+
+ it("renders and round-trips the plain text value field", async () => {
+ render();
+
+ const input = screen.getByTestId("textfield-value");
+ await userEvent.clear(input);
+ await userEvent.type(input, "new value");
+
+ expect(readFormikValues().value).toBe("new value");
+ });
+
+ it("renders and round-trips the html value field", async () => {
+ render(
+ hi
" }}
+ />
+ );
+
+ const editor = screen.getByTestId("editor-value");
+ await userEvent.clear(editor);
+ await userEvent.type(editor, "bye
");
+
+ expect(readFormikValues().value).toBe("bye
");
+ });
+
+ it("renders and round-trips the hex color value field", async () => {
+ render(
+
+ );
+
+ // MuiFormikColorField only commits to formik on blur/Enter (it buffers
+ // drag/typing locally to avoid re-rendering on every keystroke/frame).
+ const colorInput = screen.getByTestId("color-input");
+ fireEvent.change(colorInput, { target: { value: "#ff0000" } });
+ fireEvent.blur(colorInput);
+
+ expect(readFormikValues().value).toBe("#ff0000");
+ });
+
+ it("holds a newly picked file in formik state for the FILE type", async () => {
+ render();
+
+ await userEvent.click(screen.getByRole("button", { name: "upload-file" }));
+
+ expect(readFormikValues().file_preview).toBe("blob:new-file");
+ expect(
+ screen.getByRole("button", { name: "remove-file" })
+ ).toBeInTheDocument();
+ });
+
+ it("blocks submit and surfaces required-field errors for an empty text setting", async () => {
+ const onSubmit = jest.fn();
+ render(
+
+ );
+
+ await clickSave();
+
+ const errors = readFormikErrors();
+ expect(errors.key).toBeTruthy();
+ expect(errors.value).toBeTruthy();
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it("blocks submit for a FILE setting with no file attached", async () => {
+ const onSubmit = jest.fn();
+ render(
+
+ );
+
+ await clickSave();
+
+ expect(readFormikErrors().file_preview).toBeTruthy();
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it("does not require value for a FILE setting once a file is attached", async () => {
+ const onSubmit = jest.fn();
+ render(
+
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: "upload-file" }));
+ await clickSave();
+
+ expect(readFormikErrors().file_preview).toBeUndefined();
+ expect(onSubmit).toHaveBeenCalled();
+ });
+
+ it("removing the file for an existing setting deletes it and resets id to 0", async () => {
+ const onDeleteImage = jest.fn(() => Promise.resolve());
+ render(
+
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: "remove-file" }));
+
+ expect(onDeleteImage).toHaveBeenCalledWith(5);
+ expect(readFormikValues().id).toBe(0);
+ });
+});
diff --git a/src/components/forms/marketing-setting-form.js b/src/components/forms/marketing-setting-form.js
index f1b811c0f..fbd02dacc 100644
--- a/src/components/forms/marketing-setting-form.js
+++ b/src/components/forms/marketing-setting-form.js
@@ -12,16 +12,19 @@
* */
import React from "react";
+import PropTypes from "prop-types";
import T from "i18n-react/dist/i18n-react";
-import "awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css";
-import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"
-import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"
+import { useFormikContext } from "formik";
+import Box from "@mui/material/Box";
+import { Grid2 } from "@mui/material";
+import MenuItem from "@mui/material/MenuItem";
+import FormHelperText from "@mui/material/FormHelperText";
+import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield";
+import MuiFormikSelect from "openstack-uicore-foundation/lib/components/mui/formik-inputs/select";
import UploadInput from "openstack-uicore-foundation/lib/components/inputs/upload-input";
import TextEditorV3 from "openstack-uicore-foundation/lib/components/inputs/editor-input-v3";
-import Swal from "sweetalert2";
-import { isEmpty, scrollToError, shallowEqual } from "../../utils/methods";
-import history from "../../history";
-import HexColorInput from "../inputs/hex-color-input";
+import useScrollToError from "../../hooks/useScrollToError";
+import MuiFormikColorField from "../mui/formik-inputs/mui-formik-color-field";
import {
MARKETING_SETTING_TYPE_FILE,
MARKETING_SETTING_TYPE_HEX_COLOR,
@@ -29,235 +32,153 @@ import {
MARKETING_SETTING_TYPE_TEXTAREA
} from "../../utils/constants";
-const setting_types_ddl = [
+const settingTypesDdl = [
{ label: "Plain Text", value: MARKETING_SETTING_TYPE_TEXT },
{ label: "Html", value: MARKETING_SETTING_TYPE_TEXTAREA },
{ label: "File", value: MARKETING_SETTING_TYPE_FILE },
{ label: "Hex Color", value: MARKETING_SETTING_TYPE_HEX_COLOR }
];
-class MarketingSettingForm extends React.Component {
- constructor(props) {
- super(props);
+const MarketingSettingForm = ({ onDeleteImage }) => {
+ const formik = useFormikContext();
+ const { values, errors, touched, submitCount, setFieldValue, setValues } =
+ formik;
- this.state = {
- entity: { ...props.entity },
- errors: props.errors
- };
+ useScrollToError(formik, true);
- this.handleChange = this.handleChange.bind(this);
- this.handleSubmit = this.handleSubmit.bind(this);
- this.handleUploadFile = this.handleUploadFile.bind(this);
- this.handleRemoveFile = this.handleRemoveFile.bind(this);
- }
+ const handleFieldChange = (ev) => {
+ setFieldValue(ev.target.id, ev.target.value);
+ };
- componentDidUpdate(prevProps) {
- const state = {};
- scrollToError(this.props.errors);
+ const handleUploadFile = (file) => {
+ // Batch both updates - two sequential setFieldValue calls each trigger
+ // their own synchronous re-render on React 16 (no auto-batching outside
+ // synthetic events), so UploadInput's value prop would briefly be the
+ // raw File object (file_preview not yet set) and crash on value.split().
+ setValues({ ...values, file, file_preview: file.preview });
+ };
- if (!shallowEqual(prevProps.entity, this.props.entity)) {
- state.entity = { ...this.props.entity };
- state.errors = {};
- }
-
- if (!shallowEqual(prevProps.errors, this.props.errors)) {
- state.errors = { ...this.props.errors };
- }
-
- if (!isEmpty(state)) {
- this.setState({ ...this.state, ...state });
- }
- }
-
- handleChange(ev) {
- const newEntity = { ...this.state.entity };
- const newErrors = { ...this.state.errors };
- let { value, id } = ev.target;
-
- if (ev.target.type === "checkbox") {
- value = ev.target.checked;
- }
-
- if (ev.target.type === "number") {
- value = parseInt(ev.target.value);
- }
-
- newErrors[id] = "";
- newEntity[id] = value;
- this.setState({ entity: newEntity, errors: newErrors });
- }
-
- handleSubmit(ev) {
- ev.preventDefault();
- const { entity } = this.state;
- const { currentSummit } = this.props;
- if (
- (entity.type !== MARKETING_SETTING_TYPE_FILE && !entity.value) ||
- (entity.type === MARKETING_SETTING_TYPE_FILE && !entity.file)
- ) {
- const msg = `${
- setting_types_ddl.find((e) => e.value === entity.type)?.label
- }: This field may not be blank.`;
- return Swal.fire("Validation error", msg, "warning");
- }
-
- this.props.onSubmit(entity, entity.file).then((payload) => {
- if (entity.id && entity.id > 0) {
- // UPDATE
- this.props.showSuccessMessage(T.translate("marketing.setting_saved"));
- return;
- }
-
- const success_message = {
- title: T.translate("general.done"),
- html: T.translate("marketing.setting_created"),
- type: "success"
- };
-
- this.props.showMessage(success_message, () => {
- history.push(
- `/app/summits/${currentSummit.id}/marketing/${payload.response.id}`
- );
- });
- });
- }
+ const handleRemoveFile = () => {
+ setValues({ ...values, file: "", file_preview: "" });
- hasErrors(field) {
- const { errors } = this.state;
- if (field in errors) {
- return errors[field];
- }
-
- return "";
- }
-
- handleUploadFile(file) {
- const newEntity = { ...this.state.entity };
-
- newEntity.file = file;
- newEntity.file_preview = file.preview;
-
- this.setState({ entity: newEntity });
- }
-
- handleRemoveFile() {
- const newEntity = { ...this.state.entity };
-
- newEntity.file_preview = "";
- newEntity.file = "";
-
- if (newEntity.id) {
- this.props.onDeleteImage(newEntity.id).then(() => {
- newEntity.id = 0;
+ if (values.id) {
+ onDeleteImage(values.id).then(() => {
+ setFieldValue("id", 0);
});
}
-
- this.setState({ entity: newEntity });
- }
-
- render() {
- const { entity } = this.state;
-
- return (
-
- );
- }
-}
+ {fileError && {fileError}}
+
+
+ )}
+ {values.type === MARKETING_SETTING_TYPE_HEX_COLOR && (
+
+
+
+
+ )}
+
+
+ );
+};
+
+MarketingSettingForm.propTypes = {
+ onDeleteImage: PropTypes.func.isRequired
+};
export default MarketingSettingForm;
diff --git a/src/pages/marketing/__tests__/marketing-setting-list-page.test.js b/src/pages/marketing/__tests__/marketing-setting-list-page.test.js
index d14a499fb..690e07da9 100644
--- a/src/pages/marketing/__tests__/marketing-setting-list-page.test.js
+++ b/src/pages/marketing/__tests__/marketing-setting-list-page.test.js
@@ -1,5 +1,5 @@
import React from "react";
-import { act, render, screen } from "@testing-library/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";
@@ -30,42 +30,27 @@ jest.mock("../../../components/summit-dropdown", () => ({
)
}));
-let capturedColumns;
-
jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({
__esModule: true,
- default: ({
- onEdit,
- onDelete,
- onSort,
- onPageChange,
- onPerPageChange,
- columns
- }) => {
- capturedColumns = columns;
- return (
-
-
-
-
-
-
-
- );
- }
+ default: ({ onEdit, onDelete, onSort, onPageChange, onPerPageChange }) => (
+
+
+
+
+
+
+
+ )
}));
jest.mock(
@@ -118,24 +103,6 @@ describe("MarketingSettingListPage", () => {
showConfirmDialog.mockResolvedValue(true);
});
- it("bounds the value column width and wraps long values instead of overflowing", () => {
- renderWithRedux(, {
- initialState
- });
-
- const valueColumn = capturedColumns.find((c) => c.columnKey === "value");
- expect(valueColumn.width).toBe(450);
-
- const longValue = "lorem ipsum ".repeat(50);
- const { container } = render(valueColumn.render({ value: longValue }));
-
- expect(container.firstChild).toHaveStyle({
- wordBreak: "break-word",
- overflowWrap: "anywhere"
- });
- expect(container).toHaveTextContent(longValue.trim());
- });
-
it("deletes the setting by id (confirm is handled inside MuiTable)", async () => {
renderWithRedux(, {
initialState
diff --git a/src/pages/marketing/edit-marketing-setting-page.js b/src/pages/marketing/edit-marketing-setting-page.js
index d65533c3f..02fdac924 100644
--- a/src/pages/marketing/edit-marketing-setting-page.js
+++ b/src/pages/marketing/edit-marketing-setting-page.js
@@ -11,14 +11,15 @@
* limitations under the License.
* */
-import React from "react";
+import React, { useEffect } from "react";
import { connect } from "react-redux";
import T from "i18n-react/dist/i18n-react";
import { Breadcrumb } from "react-breadcrumbs";
-import {
- showMessage,
- showSuccessMessage
-} from "openstack-uicore-foundation/lib/utils/actions";
+import { FormikProvider, useFormik } from "formik";
+import * as yup from "yup";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
+import { useSnackbarMessage } from "openstack-uicore-foundation/lib/components/mui/snackbar-notification";
import MarketingSettingForm from "../../components/forms/marketing-setting-form";
import { getSummitById } from "../../actions/summit-actions";
import {
@@ -27,64 +28,119 @@ import {
saveMarketingSetting,
deleteSetting
} from "../../actions/marketing-actions";
-import "../../styles/edit-marketing-setting-page.less";
import AddNewButton from "../../components/buttons/add-new-button";
+import { requiredStringValidation } from "../../utils/yup";
+import { MARKETING_SETTING_TYPE_FILE } from "../../utils/constants";
+// import '../../styles/edit-marketing-setting-page.less';
+
+export const buildValues = (entity) => ({
+ id: entity?.id ?? 0,
+ key: entity?.key ?? "",
+ type: entity?.type ?? "",
+ value: entity?.value ?? "",
+ file_preview: entity?.file_preview ?? "",
+ file: entity?.file ?? null,
+ selection_plan_id: entity?.selection_plan_id ?? ""
+});
-class EditMarketingSettingPage extends React.Component {
- constructor(props) {
- const settingId = props.match.params.setting_id;
- super(props);
+export const validationSchema = yup.object().shape({
+ key: requiredStringValidation(),
+ type: requiredStringValidation(),
+ value: yup
+ .string()
+ .nullable()
+ .when("type", {
+ is: (type) => type !== MARKETING_SETTING_TYPE_FILE,
+ then: (schema) => schema.required(T.translate("validation.required")),
+ otherwise: (schema) => schema
+ }),
+ file: yup.mixed().nullable(),
+ file_preview: yup
+ .string()
+ .nullable()
+ .when(["type", "file"], {
+ is: (type, file) => type === MARKETING_SETTING_TYPE_FILE && !file,
+ then: (schema) =>
+ schema.required(T.translate("validation.file_required")),
+ otherwise: (schema) => schema
+ })
+});
+const EditMarketingSettingPage = ({
+ currentSummit,
+ entity,
+ match,
+ history,
+ getMarketingSetting,
+ resetSettingForm,
+ saveMarketingSetting,
+ deleteSetting
+}) => {
+ const settingId = match.params.setting_id;
+ const { successMessage } = useSnackbarMessage();
+
+ useEffect(() => {
if (!settingId) {
- props.resetSettingForm();
+ resetSettingForm();
} else {
- props.getMarketingSetting(settingId);
+ getMarketingSetting(settingId);
}
- }
+ }, [settingId]);
- componentDidUpdate(prevProps) {
- const oldId = prevProps.match.params.setting_id;
- const newId = this.props.match.params.setting_id;
+ const formik = useFormik({
+ initialValues: buildValues(entity),
+ validationSchema,
+ onSubmit: (values) =>
+ saveMarketingSetting(values, values.file)
+ .then(() => {
+ successMessage(
+ T.translate(
+ values.id
+ ? "marketing.setting_saved"
+ : "marketing.setting_created"
+ )
+ );
+ history.push(`/app/summits/${currentSummit.id}/marketing`);
+ })
+ .catch(() => {})
+ });
- if (oldId !== newId) {
- if (!newId) {
- this.props.resetSettingForm();
- } else {
- this.props.getMarketingSetting(newId);
- }
- }
- }
+ useEffect(() => {
+ formik.resetForm({ values: buildValues(entity) });
+ }, [entity.id]);
- render() {
- const { currentSummit, entity, errors, match } = this.props;
- const title = entity.id
- ? T.translate("general.edit")
- : T.translate("general.add");
- const breadcrumb = entity.id ? entity.key : T.translate("general.new");
+ const title = entity.id
+ ? T.translate("general.edit")
+ : T.translate("general.add");
+ const breadcrumb = entity.id ? entity.key : T.translate("general.new");
- return (
-
-
-
- {title} {T.translate("marketing.marketing_setting")}
-
-
-
- {currentSummit && (
-
- )}
-
- );
- }
-}
+ return (
+
+
+
+ {title} {T.translate("marketing.marketing_setting")}
+
+
+
+ {currentSummit && (
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
const mapStateToProps = ({ currentSummitState, marketingSettingState }) => ({
currentSummit: currentSummitState.currentSummit,
@@ -96,7 +152,5 @@ export default connect(mapStateToProps, {
getMarketingSetting,
resetSettingForm,
saveMarketingSetting,
- deleteSetting,
- showMessage,
- showSuccessMessage
+ deleteSetting
})(EditMarketingSettingPage);
diff --git a/src/pages/marketing/marketing-setting-list-page.js b/src/pages/marketing/marketing-setting-list-page.js
index e3d622255..a15257c09 100644
--- a/src/pages/marketing/marketing-setting-list-page.js
+++ b/src/pages/marketing/marketing-setting-list-page.js
@@ -115,13 +115,12 @@ const MarketingSettingListPage = ({
{
columnKey: "value",
header: T.translate("marketing.value"),
- width: 450,
+ width: 300,
render: (row) => wrapLongText(row.value)
},
{
columnKey: "selection_plan_id",
- header: T.translate("marketing.selection_plan"),
- render: (row) => wrapLongText(row.selection_plan_id)
+ header: T.translate("marketing.selection_plan")
}
];
diff --git a/src/reducers/marketing/__tests__/marketing-setting-list-reducer.test.js b/src/reducers/marketing/__tests__/marketing-setting-list-reducer.test.js
new file mode 100644
index 000000000..f3a9b63b7
--- /dev/null
+++ b/src/reducers/marketing/__tests__/marketing-setting-list-reducer.test.js
@@ -0,0 +1,74 @@
+import {
+ REQUEST_SETTINGS,
+ RECEIVE_SETTINGS
+} from "../../../actions/marketing-actions";
+import marketingSettingListReducer from "../marketing-setting-list-reducer";
+
+const DEFAULT_STATE = {
+ settings: [],
+ term: null,
+ order: "id",
+ orderDir: 1,
+ currentPage: 1,
+ lastPage: 1,
+ perPage: 10,
+ totalSettings: 0
+};
+
+describe("marketingSettingListReducer", () => {
+ test("REQUEST_SETTINGS stores the requested page and page size", () => {
+ const state = marketingSettingListReducer(DEFAULT_STATE, {
+ type: REQUEST_SETTINGS,
+ payload: {
+ term: "banner",
+ currentPage: 2,
+ perPage: 50,
+ order: "key",
+ orderDir: 0
+ }
+ });
+
+ expect(state).toMatchObject({
+ term: "banner",
+ currentPage: 2,
+ perPage: 50,
+ order: "key",
+ orderDir: 0
+ });
+ });
+
+ test("RECEIVE_SETTINGS does not revert the page size REQUEST_SETTINGS just stored", () => {
+ const requested = marketingSettingListReducer(DEFAULT_STATE, {
+ type: REQUEST_SETTINGS,
+ payload: {
+ term: null,
+ currentPage: 1,
+ perPage: 50,
+ order: "id",
+ orderDir: 1
+ }
+ });
+
+ const state = marketingSettingListReducer(requested, {
+ type: RECEIVE_SETTINGS,
+ payload: {
+ response: {
+ data: [
+ {
+ id: 1,
+ key: "SOME_KEY",
+ type: "TEXT",
+ value: "hi",
+ selection_plan_id: null
+ }
+ ],
+ total: 1,
+ current_page: 1,
+ last_page: 1
+ }
+ }
+ });
+
+ expect(state.perPage).toBe(50);
+ });
+});
diff --git a/src/reducers/marketing/marketing-setting-list-reducer.js b/src/reducers/marketing/marketing-setting-list-reducer.js
index 286b395da..c79947b0e 100644
--- a/src/reducers/marketing/marketing-setting-list-reducer.js
+++ b/src/reducers/marketing/marketing-setting-list-reducer.js
@@ -9,8 +9,9 @@
* 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 { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions";
import {
RECEIVE_SETTINGS,
REQUEST_SETTINGS,
@@ -18,7 +19,6 @@ import {
} from "../../actions/marketing-actions";
import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions";
-import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions";
const DEFAULT_STATE = {
settings: [],
@@ -39,32 +39,30 @@ const marketingSettingListReducer = (state = DEFAULT_STATE, action) => {
return DEFAULT_STATE;
}
case REQUEST_SETTINGS: {
- let { order, orderDir, term } = payload;
+ const { order, orderDir, term, currentPage, perPage } = payload;
- return { ...state, order, orderDir, term };
+ return { ...state, order, orderDir, term, currentPage, perPage };
}
case RECEIVE_SETTINGS: {
- let { total, last_page, current_page } = payload.response;
- let settings = payload.response.data.map((s) => {
- return {
+ const { total, last_page, current_page } = payload.response;
+ const settings = payload.response.data.map((s) => ({
id: s.id,
key: s.key,
type: s.type,
value: s.value,
selection_plan_id: s.selection_plan_id ? s.selection_plan_id : "N/A"
- };
- });
+ }));
return {
...state,
- settings: settings,
+ settings,
currentPage: current_page,
totalSettings: total,
lastPage: last_page
};
}
case SETTING_DELETED: {
- let { settingId } = payload;
+ const { settingId } = payload;
return {
...state,
settings: state.settings.filter((s) => s.id !== settingId)
From 586cd5c48e7916703eb32b9e3475a4d1d6a25d2a Mon Sep 17 00:00:00 2001
From: Santiago Palenque
Date: Tue, 15 Sep 2026 12:42:30 -0300
Subject: [PATCH 5/6] chore: pr review
---
src/components/forms/marketing-setting-form.js | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/src/components/forms/marketing-setting-form.js b/src/components/forms/marketing-setting-form.js
index fbd02dacc..dd4c54f49 100644
--- a/src/components/forms/marketing-setting-form.js
+++ b/src/components/forms/marketing-setting-form.js
@@ -62,9 +62,14 @@ const MarketingSettingForm = ({ onDeleteImage }) => {
setValues({ ...values, file: "", file_preview: "" });
if (values.id) {
- onDeleteImage(values.id).then(() => {
- setFieldValue("id", 0);
- });
+ onDeleteImage(values.id)
+ .then(() => {
+ setFieldValue("id", 0);
+ })
+ .catch(() => {
+ setFieldValue("file", values.file);
+ setFieldValue("file_preview", values.file_preview);
+ });
}
};
From ae091856e7307a8805b0643f925292ebb74fea02 Mon Sep 17 00:00:00 2001
From: Santiago Palenque
Date: Tue, 15 Sep 2026 15:12:35 -0300
Subject: [PATCH 6/6] chore: add tests
---
.../edit-marketing-setting-page.test.js | 273 ++++++++++++++++++
.../marketing/edit-marketing-setting-page.js | 6 +
2 files changed, 279 insertions(+)
create mode 100644 src/pages/marketing/__tests__/edit-marketing-setting-page.test.js
diff --git a/src/pages/marketing/__tests__/edit-marketing-setting-page.test.js b/src/pages/marketing/__tests__/edit-marketing-setting-page.test.js
new file mode 100644
index 000000000..f9a4c2529
--- /dev/null
+++ b/src/pages/marketing/__tests__/edit-marketing-setting-page.test.js
@@ -0,0 +1,273 @@
+import React from "react";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { Provider } from "react-redux";
+import configureStore from "redux-mock-store";
+import thunk from "redux-thunk";
+import "@testing-library/jest-dom";
+import flushPromises from "flush-promises";
+import { renderWithRedux, createMockSummit } from "../../../utils/test-utils";
+import EditMarketingSettingPage from "../edit-marketing-setting-page";
+import {
+ getMarketingSetting,
+ resetSettingForm,
+ saveMarketingSetting,
+ deleteSetting
+} from "../../../actions/marketing-actions";
+
+jest.mock("../../../actions/marketing-actions", () => ({
+ getMarketingSetting: jest.fn(),
+ resetSettingForm: jest.fn(),
+ saveMarketingSetting: jest.fn(),
+ deleteSetting: jest.fn()
+}));
+
+jest.mock("../../../actions/summit-actions", () => ({
+ getSummitById: jest.fn()
+}));
+
+jest.mock("react-breadcrumbs", () => ({
+ Breadcrumb: () => null
+}));
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+const mockSuccessMessage = jest.fn();
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/snackbar-notification",
+ () => ({
+ __esModule: true,
+ useSnackbarMessage: () => ({
+ successMessage: mockSuccessMessage,
+ errorMessage: jest.fn()
+ })
+ })
+);
+
+// Stands in for the real form: exposes plain inputs bound to the same
+// formik context the page provides, so tests can drive values/read errors
+// without depending on marketing-setting-form.js's own rendering (covered
+// by marketing-setting-form.test.js).
+jest.mock("../../../components/forms/marketing-setting-form", () => {
+ const React = require("react");
+ const { useFormikContext } = require("formik");
+ return {
+ __esModule: true,
+ default: function MockMarketingSettingForm() {
+ const { values, errors, setFieldValue } = useFormikContext();
+ return (
+
+ );
+ }
+ };
+});
+
+const mockHistory = { push: jest.fn() };
+
+const buildEntity = (overrides = {}) => ({
+ id: 0,
+ key: "",
+ type: "",
+ value: "",
+ file_preview: "",
+ file: null,
+ selection_plan_id: "",
+ ...overrides
+});
+
+const buildInitialState = (entityOverrides = {}, errors = {}) => ({
+ currentSummitState: { currentSummit: createMockSummit() },
+ marketingSettingState: { entity: buildEntity(entityOverrides), errors }
+});
+
+const clickSave = () => screen.getByRole("button", { name: "general.save" });
+
+describe("EditMarketingSettingPage", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ getMarketingSetting.mockReturnValue(() => Promise.resolve());
+ resetSettingForm.mockReturnValue(() => Promise.resolve());
+ deleteSetting.mockReturnValue(() => Promise.resolve());
+ });
+
+ it("loads an existing setting on mount", () => {
+ renderWithRedux(
+ ,
+ { initialState: buildInitialState({ id: 5, key: "existing" }) }
+ );
+
+ expect(getMarketingSetting).toHaveBeenCalledWith("5");
+ expect(resetSettingForm).not.toHaveBeenCalled();
+ });
+
+ it("resets the form for a new setting (\"add\" route, no setting_id)", () => {
+ renderWithRedux(
+ ,
+ { initialState: buildInitialState() }
+ );
+
+ expect(resetSettingForm).toHaveBeenCalled();
+ expect(getMarketingSetting).not.toHaveBeenCalled();
+ });
+
+ it("resyncs formik values when the loaded entity changes", () => {
+ const middlewares = [thunk];
+ const mockStore = configureStore(middlewares);
+ const match = { params: { setting_id: "5" }, url: "/x" };
+
+ const storeA = mockStore(
+ buildInitialState({ id: 5, key: "entity-a", type: "TEXT" })
+ );
+ const { rerender } = render(
+
+
+
+ );
+
+ expect(screen.getByTestId("key-input")).toHaveValue("entity-a");
+
+ const storeB = mockStore(
+ buildInitialState({ id: 7, key: "entity-b", type: "TEXT" })
+ );
+ rerender(
+
+
+
+ );
+
+ expect(screen.getByTestId("key-input")).toHaveValue("entity-b");
+ });
+
+ it("saves an update, shows the update message, and navigates to the list", async () => {
+ saveMarketingSetting.mockReturnValue(() => Promise.resolve());
+ renderWithRedux(
+ ,
+ {
+ initialState: buildInitialState({
+ id: 5,
+ key: "existing",
+ type: "TEXT",
+ value: "hello"
+ })
+ }
+ );
+
+ await act(async () => {
+ clickSave().click();
+ await flushPromises();
+ });
+
+ expect(saveMarketingSetting).toHaveBeenCalled();
+ expect(mockSuccessMessage).toHaveBeenCalledWith("marketing.setting_saved");
+ expect(mockHistory.push).toHaveBeenCalledWith(
+ `/app/summits/${createMockSummit().id}/marketing`
+ );
+ });
+
+ it("saves a new setting, shows the created message, and navigates to the list", async () => {
+ saveMarketingSetting.mockReturnValue(() => Promise.resolve());
+ renderWithRedux(
+ ,
+ { initialState: buildInitialState() }
+ );
+
+ fireEvent.change(screen.getByTestId("key-input"), {
+ target: { value: "new-key" }
+ });
+ fireEvent.change(screen.getByTestId("type-input"), {
+ target: { value: "TEXT" }
+ });
+ fireEvent.change(screen.getByTestId("value-input"), {
+ target: { value: "hello" }
+ });
+
+ await act(async () => {
+ clickSave().click();
+ await flushPromises();
+ });
+
+ expect(saveMarketingSetting).toHaveBeenCalled();
+ expect(mockSuccessMessage).toHaveBeenCalledWith(
+ "marketing.setting_created"
+ );
+ expect(mockHistory.push).toHaveBeenCalledWith(
+ `/app/summits/${createMockSummit().id}/marketing`
+ );
+ });
+
+ it("does not navigate or show a success message when save fails", async () => {
+ saveMarketingSetting.mockReturnValue(() => Promise.reject(new Error()));
+ renderWithRedux(
+ ,
+ {
+ initialState: buildInitialState({
+ id: 5,
+ key: "existing",
+ type: "TEXT",
+ value: "hello"
+ })
+ }
+ );
+
+ await act(async () => {
+ clickSave().click();
+ await flushPromises();
+ });
+
+ expect(mockSuccessMessage).not.toHaveBeenCalled();
+ expect(mockHistory.push).not.toHaveBeenCalled();
+ });
+
+ it("surfaces a server-side field validation error without a submit", () => {
+ renderWithRedux(
+ ,
+ {
+ initialState: buildInitialState(
+ { id: 5, key: "duplicate-key", type: "TEXT", value: "hello" },
+ { key: "marketing.key_already_exists" }
+ )
+ }
+ );
+
+ expect(screen.getByTestId("key-error")).toHaveTextContent(
+ "marketing.key_already_exists"
+ );
+ });
+});
diff --git a/src/pages/marketing/edit-marketing-setting-page.js b/src/pages/marketing/edit-marketing-setting-page.js
index 02fdac924..773612dee 100644
--- a/src/pages/marketing/edit-marketing-setting-page.js
+++ b/src/pages/marketing/edit-marketing-setting-page.js
@@ -69,6 +69,7 @@ export const validationSchema = yup.object().shape({
const EditMarketingSettingPage = ({
currentSummit,
entity,
+ errors,
match,
history,
getMarketingSetting,
@@ -109,6 +110,11 @@ const EditMarketingSettingPage = ({
formik.resetForm({ values: buildValues(entity) });
}, [entity.id]);
+ useEffect(() => {
+ const errorFields = Object.keys(errors || {});
+ formik.setErrors(errorFields.length > 0 ? errors : {});
+ }, [errors]);
+
const title = entity.id
? T.translate("general.edit")
: T.translate("general.add");