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}) +

+ + + + + + + + {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 ( -
- -
-
- - 0 && errors.file_preview; + + return ( + + + + + + selected + ? settingTypesDdl.find((opt) => opt.value === selected)?.label + : T.translate("marketing.placeholders.select_type") + } + > + {settingTypesDdl.map((opt) => ( + + {opt.label} + + ))} + + + + + + + + + + + + + + {values.type === MARKETING_SETTING_TYPE_TEXT && ( + + + -
-
- - + )} + {values.type === MARKETING_SETTING_TYPE_TEXTAREA && ( + + + -
-
- - -
-
-
- {entity.type === MARKETING_SETTING_TYPE_TEXT && ( -
- - -
- )} - {entity.type === MARKETING_SETTING_TYPE_TEXTAREA && ( -
- - -
- )} - {entity.type === MARKETING_SETTING_TYPE_FILE && ( -
- + + )} + {values.type === MARKETING_SETTING_TYPE_FILE && ( + + + {/* need this styles to adapt bootstrap to MUI */} + :first-of-type": { flex: 1 }, + "& .selected-files-box": { flex: "0 0 auto", maxWidth: "50%" } + }} + > -
- )} - {entity.type === MARKETING_SETTING_TYPE_HEX_COLOR && ( -
- - -
- )} -
- -
-
- -
-
- - ); - } -} + {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 ( +
+ setFieldValue("key", ev.target.value)} + /> + setFieldValue("type", ev.target.value)} + /> + setFieldValue("value", ev.target.value)} + /> + {errors.key &&

{errors.key}

} +
+ ); + } + }; +}); + +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");