From 7fc8a3ff1d13f8eafff1a827451182bc01756638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Mon, 12 Oct 2026 14:05:02 -0300 Subject: [PATCH 1/9] fix: add 'new button' using mui components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/buttons/add-new-button-mui.js | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/components/buttons/add-new-button-mui.js diff --git a/src/components/buttons/add-new-button-mui.js b/src/components/buttons/add-new-button-mui.js new file mode 100644 index 000000000..ecc3511d0 --- /dev/null +++ b/src/components/buttons/add-new-button-mui.js @@ -0,0 +1,22 @@ +import React from "react"; +import PropTypes from "prop-types"; +import Button from "@mui/material/Button"; +import AddIcon from "@mui/icons-material/Add"; +import T from "i18n-react"; + +const AddNewButton = ({ onClick }) => ( + +); + +AddNewButton.propTypes = { + onClick: PropTypes.func.isRequired +}; + +export default AddNewButton; From 2a372acc58bc1640355ccc66201668725adc2bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 8 Oct 2026 00:39:33 -0300 Subject: [PATCH 2/9] fix: migrate edit view type page into mui/uicore components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/badge-actions.js | 49 +++-- .../forms/__tests__/view-type-form.test.js | 50 +++++ src/components/forms/view-type-form.js | 206 ++++++++---------- src/pages/badges/edit-view-type-page.js | 108 +++------ 4 files changed, 203 insertions(+), 210 deletions(-) create mode 100644 src/components/forms/__tests__/view-type-form.test.js diff --git a/src/actions/badge-actions.js b/src/actions/badge-actions.js index 79a09182d..11115e290 100644 --- a/src/actions/badge-actions.js +++ b/src/actions/badge-actions.js @@ -520,9 +520,7 @@ export const getViewType = (viewTypeId) => async (dispatch, getState) => { snackbarErrorHandler )(params)(dispatch) .catch(() => {}) - .finally(() => { - dispatch(stopLoading()); - }); + .finally(() => dispatch(stopLoading())); }; export const resetViewTypeForm = () => (dispatch) => { @@ -550,18 +548,18 @@ export const saveViewType = (entity) => async (dispatch, getState) => { normalizedEntity, snackbarErrorHandler, entity - )(params)(dispatch).then(() => { - dispatch( - showSuccessMessage(T.translate("edit_view_type.view_type_saved")) - ); - }); + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("edit_view_type.view_type_saved") + }) + ); + }) + .finally(() => dispatch(stopLoading())) + .catch(() => {}); } else { - const success_message = { - title: T.translate("general.done"), - html: T.translate("edit_view_type.view_type_created"), - type: "success" - }; - postRequest( createAction(UPDATE_VIEW_TYPE), createAction(VIEW_TYPE_ADDED), @@ -569,15 +567,20 @@ export const saveViewType = (entity) => async (dispatch, getState) => { normalizedEntity, snackbarErrorHandler, entity - )(params)(dispatch).then((payload) => { - dispatch( - showMessage(success_message, () => { - history.push( - `/app/summits/${currentSummit.id}/view-types/${payload.response.id}` - ); - }) - ); - }); + )(params)(dispatch) + .then((payload) => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("edit_view_type.view_type_created") + }) + ); + history.push( + `/app/summits/${currentSummit.id}/view-types/${payload.response.id}` + ); + }) + .finally(() => dispatch(stopLoading())) + .catch(() => {}); } }; diff --git a/src/components/forms/__tests__/view-type-form.test.js b/src/components/forms/__tests__/view-type-form.test.js new file mode 100644 index 000000000..42300367d --- /dev/null +++ b/src/components/forms/__tests__/view-type-form.test.js @@ -0,0 +1,50 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import ViewTypeForm from "../view-type-form"; + +// jsdom has no scrollIntoView; scrollToError calls it for the name error +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/editor-input-v3", + () => ({ + __esModule: true, + default: () => null + }) +); + +const ENTITY = { id: 3, name: "Card", description: "", is_default: null }; + +describe("ViewTypeForm", () => { + it("clears a field error on edit and submits the edited entity", async () => { + const onSubmit = jest.fn(); + render( + + ); + const name = screen.getByRole("textbox", { name: /edit_view_type.name/ }); + + expect(screen.getByText("name taken")).toBeInTheDocument(); + await userEvent.type(name, " Print"); + expect(screen.queryByText("name taken")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("checkbox")); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + expect(onSubmit).toHaveBeenCalledWith({ + ...ENTITY, + name: "Card Print", + is_default: true + }); + }); +}); diff --git a/src/components/forms/view-type-form.js b/src/components/forms/view-type-form.js index 3f25f12f3..3be004ad5 100644 --- a/src/components/forms/view-type-form.js +++ b/src/components/forms/view-type-form.js @@ -11,129 +11,107 @@ * limitations under the License. * */ -import React from "react"; -import T from "i18n-react"; -import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"; +import React, { useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Grid2 from "@mui/material/Grid2"; +import InputLabel from "@mui/material/InputLabel"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; import TextEditorV3 from "openstack-uicore-foundation/lib/components/inputs/editor-input-v3"; -import { - hasErrors, - isEmpty, - scrollToError, - shallowEqual -} from "../../utils/methods"; +import { hasErrors, scrollToError } from "../../utils/methods"; -class ViewTypeForm extends React.Component { - constructor(props) { - super(props); +const ViewTypeForm = ({ entity: entityProp, errors: errorsProp, onSubmit }) => { + const [entity, setEntity] = useState({ ...entityProp }); + const [errors, setErrors] = useState(errorsProp); - this.state = { - entity: { ...props.entity }, - errors: props.errors - }; + useEffect(() => { + setEntity({ ...entityProp }); + setErrors({}); + }, [entityProp]); - this.handleChange = this.handleChange.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - } + useEffect(() => { + setErrors({ ...errorsProp }); + scrollToError(errorsProp); + }, [errorsProp]); - componentDidUpdate(prevProps) { - const state = {}; - scrollToError(this.props.errors); + const handleChange = (ev) => { + const { id, type, checked } = ev.target; + const value = type === "checkbox" ? checked : ev.target.value; - if (!shallowEqual(prevProps.entity, this.props.entity)) { - state.entity = { ...this.props.entity }; - state.errors = {}; - } + setErrors({ ...errors, [id]: "" }); + setEntity({ ...entity, [id]: value }); + }; - if (!shallowEqual(prevProps.errors, this.props.errors)) { - state.errors = { ...this.props.errors }; - } - - if (!isEmpty(state)) { - this.setState({ ...this.state, ...state }); - } - } - - handleChange(ev) { - const entity = { ...this.state.entity }; - const errors = { ...this.state.errors }; - let { value, id } = ev.target; - - if (ev.target.type === "checkbox") { - value = ev.target.checked; - } - - errors[id] = ""; - entity[id] = value; - this.setState({ entity, errors }); - } - - handleSubmit(ev) { + const handleSubmit = (ev) => { ev.preventDefault(); - this.props.onSubmit(this.state.entity); - } - - render() { - const { entity, errors } = this.state; - - return ( -
- -
-
- - -
-
-
-
- - -
-
-
-
-
- + + + + + + + {T.translate("edit_view_type.description")} + + + + + - -
-
-
- -
- -
-
- -
-
-
- ); - } -} + } + label={T.translate("edit_view_type.is_default")} + /> + + + + + + + + ); +}; + +ViewTypeForm.propTypes = { + entity: PropTypes.object.isRequired, + errors: PropTypes.object, + onSubmit: PropTypes.func.isRequired +}; + +ViewTypeForm.defaultProps = { + errors: {} +}; export default ViewTypeForm; diff --git a/src/pages/badges/edit-view-type-page.js b/src/pages/badges/edit-view-type-page.js index 4cef64b89..c5a66590d 100644 --- a/src/pages/badges/edit-view-type-page.js +++ b/src/pages/badges/edit-view-type-page.js @@ -11,92 +11,55 @@ * limitations under the License. * */ -import React from "react"; +import React, { useEffect } from "react"; import { connect } from "react-redux"; import { Breadcrumb } from "react-breadcrumbs"; import T from "i18n-react/dist/i18n-react"; -import { getSummitById } from "../../actions/summit-actions"; import ViewTypeForm from "../../components/forms/view-type-form"; - import { getViewType, resetViewTypeForm, saveViewType } from "../../actions/badge-actions"; -import AddNewButton from "../../components/buttons/add-new-button"; +import AddNewButtonMUI from "../../components/buttons/add-new-button-mui"; -class EditViewPagePage extends React.Component { - constructor(props) { - const viewTypeId = props.match.params.view_type_id; - super(props); +const EditViewTypePage = ({ + currentSummit, + entity, + errors, + match, + getViewType, + resetViewTypeForm, + saveViewType +}) => { + const viewTypeId = match.params.view_type_id; + const title = entity.id + ? T.translate("general.edit") + : T.translate("general.add"); + const breadcrumb = entity.id ? entity.name : T.translate("general.new"); + useEffect(() => { if (!viewTypeId) { - props.resetViewTypeForm(); + resetViewTypeForm(); } else { - props.getViewType(viewTypeId); + getViewType(viewTypeId); } + }, [viewTypeId]); - this.handleSubmit = this.handleSubmit.bind(this); - } - - componentDidUpdate(prevProps, prevState, snapshot) { - const oldId = prevProps.match.params.view_type_id; - const newId = this.props.match.params.view_type_id; - - if (oldId !== newId) { - if (!newId) { - this.props.resetViewTypeForm(); - } else { - this.props.getViewType(newId); - } - } - } - - handleSubmit(entity) { - this.props.saveViewType(entity); - } - - render() { - const { currentSummit, entity, errors, match, history } = this.props; - const title = entity.id - ? T.translate("general.edit") - : T.translate("general.add"); - const breadcrumb = entity.id ? entity.name : T.translate("general.new"); - - const fields = [ - { type: "text", name: "name", label: T.translate("edit_view_type.name") }, - { - type: "textarea", - name: "description", - label: T.translate("edit_view_type.description") - }, - { - type: "checkbox", - name: "is_default", - label: T.translate("edit_view_type.is_default") - } - ]; - - return ( -
- -

- {title} {T.translate("edit_view_type.view_type")} - -

-
- {currentSummit && ( - - )} -
- ); - } -} + return ( +
+ +

+ {title} {T.translate("edit_view_type.view_type")} + +

+
+ {currentSummit && ( + + )} +
+ ); +}; const mapStateToProps = ({ currentSummitState, currentViewTypeState }) => ({ currentSummit: currentSummitState.currentSummit, @@ -104,8 +67,7 @@ const mapStateToProps = ({ currentSummitState, currentViewTypeState }) => ({ }); export default connect(mapStateToProps, { - getSummitById, getViewType, resetViewTypeForm, saveViewType -})(EditViewPagePage); +})(EditViewTypePage); From 73e1d28441fb3af3a900dd307c3c63859a5eb427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Mon, 12 Oct 2026 08:41:58 -0300 Subject: [PATCH 3/9] fix: use formik into form, restore add new button, adjust actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/badge-actions.js | 10 +- .../forms/__tests__/view-type-form.test.js | 47 ++++-- src/components/forms/view-type-form.js | 147 +++++++++--------- src/pages/badges/edit-view-type-page.js | 4 +- 4 files changed, 110 insertions(+), 98 deletions(-) diff --git a/src/actions/badge-actions.js b/src/actions/badge-actions.js index 11115e290..b8ec95b95 100644 --- a/src/actions/badge-actions.js +++ b/src/actions/badge-actions.js @@ -552,13 +552,12 @@ export const saveViewType = (entity) => async (dispatch, getState) => { .then(() => { dispatch( snackbarSuccessHandler({ - title: T.translate("general.success"), html: T.translate("edit_view_type.view_type_saved") }) ); }) - .finally(() => dispatch(stopLoading())) - .catch(() => {}); + .catch(() => {}) + .finally(() => dispatch(stopLoading())); } else { postRequest( createAction(UPDATE_VIEW_TYPE), @@ -571,7 +570,6 @@ export const saveViewType = (entity) => async (dispatch, getState) => { .then((payload) => { dispatch( snackbarSuccessHandler({ - title: T.translate("general.success"), html: T.translate("edit_view_type.view_type_created") }) ); @@ -579,8 +577,8 @@ export const saveViewType = (entity) => async (dispatch, getState) => { `/app/summits/${currentSummit.id}/view-types/${payload.response.id}` ); }) - .finally(() => dispatch(stopLoading())) - .catch(() => {}); + .catch(() => {}) + .finally(() => dispatch(stopLoading())); } }; diff --git a/src/components/forms/__tests__/view-type-form.test.js b/src/components/forms/__tests__/view-type-form.test.js index 42300367d..8345b9917 100644 --- a/src/components/forms/__tests__/view-type-form.test.js +++ b/src/components/forms/__tests__/view-type-form.test.js @@ -1,29 +1,31 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; import ViewTypeForm from "../view-type-form"; -// jsdom has no scrollIntoView; scrollToError calls it for the name error -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/editor-input-v3", + "openstack-uicore-foundation/lib/components/mui/formik-inputs/texteditor", () => ({ __esModule: true, default: () => null }) ); +jest.mock("../../../hooks/useScrollToError", () => ({ + __esModule: true, + default: jest.fn() +})); + const ENTITY = { id: 3, name: "Card", description: "", is_default: null }; describe("ViewTypeForm", () => { - it("clears a field error on edit and submits the edited entity", async () => { + it("shows server errors and submits the edited entity", async () => { const onSubmit = jest.fn(); render( { onSubmit={onSubmit} /> ); - const name = screen.getByRole("textbox", { name: /edit_view_type.name/ }); - expect(screen.getByText("name taken")).toBeInTheDocument(); - await userEvent.type(name, " Print"); - expect(screen.queryByText("name taken")).not.toBeInTheDocument(); + expect(await screen.findByText("name taken")).toBeInTheDocument(); + await userEvent.type( + screen.getByRole("textbox", { name: /edit_view_type.name/ }), + " Print" + ); await userEvent.click(screen.getByRole("checkbox")); await userEvent.click(screen.getByRole("button", { name: "general.save" })); - expect(onSubmit).toHaveBeenCalledWith({ - ...ENTITY, - name: "Card Print", - is_default: true - }); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + ...ENTITY, + name: "Card Print", + is_default: true + }) + ); + }); + + it("blocks submit when name is empty", async () => { + const onSubmit = jest.fn(); + render( + + ); + + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + expect(await screen.findByText("validation.required")).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); }); }); diff --git a/src/components/forms/view-type-form.js b/src/components/forms/view-type-form.js index 3be004ad5..e2f3c86b5 100644 --- a/src/components/forms/view-type-form.js +++ b/src/components/forms/view-type-form.js @@ -11,96 +11,93 @@ * limitations under the License. * */ -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import PropTypes from "prop-types"; +import { useFormik, FormikProvider } from "formik"; +import * as yup from "yup"; import T from "i18n-react/dist/i18n-react"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Checkbox from "@mui/material/Checkbox"; -import FormControlLabel from "@mui/material/FormControlLabel"; import Grid2 from "@mui/material/Grid2"; import InputLabel from "@mui/material/InputLabel"; import Stack from "@mui/material/Stack"; -import TextField from "@mui/material/TextField"; -import TextEditorV3 from "openstack-uicore-foundation/lib/components/inputs/editor-input-v3"; -import { hasErrors, scrollToError } from "../../utils/methods"; +import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield"; +import MuiFormikCheckbox from "openstack-uicore-foundation/lib/components/mui/formik-inputs/checkbox"; +import FormikTextEditor from "openstack-uicore-foundation/lib/components/mui/formik-inputs/texteditor"; +import useScrollToError from "../../hooks/useScrollToError"; +import { requiredStringValidation } from "../../utils/yup"; -const ViewTypeForm = ({ entity: entityProp, errors: errorsProp, onSubmit }) => { - const [entity, setEntity] = useState({ ...entityProp }); - const [errors, setErrors] = useState(errorsProp); +const validationSchema = yup.object({ + name: requiredStringValidation() +}); - useEffect(() => { - setEntity({ ...entityProp }); - setErrors({}); - }, [entityProp]); - - useEffect(() => { - setErrors({ ...errorsProp }); - scrollToError(errorsProp); - }, [errorsProp]); - - const handleChange = (ev) => { - const { id, type, checked } = ev.target; - const value = type === "checkbox" ? checked : ev.target.value; +const ViewTypeForm = ({ entity, errors, onSubmit }) => { + const formik = useFormik({ + initialValues: { + name: entity.name || "", + description: entity.description || "", + is_default: !!entity.is_default + }, + validationSchema, + enableReinitialize: true, + onSubmit: (values) => onSubmit({ ...entity, ...values }) + }); - setErrors({ ...errors, [id]: "" }); - setEntity({ ...entity, [id]: value }); - }; + useScrollToError(formik); - const handleSubmit = (ev) => { - ev.preventDefault(); - onSubmit(entity); - }; - - const nameError = hasErrors("name", errors); + useEffect(() => { + const errorFields = Object.keys(errors || {}); + formik.setErrors(errorFields.length > 0 ? errors : {}); + if (errorFields.length > 0) { + formik.setTouched( + errorFields.reduce((acc, field) => ({ ...acc, [field]: true }), {}), + false + ); + } + }, [errors]); return ( - - - - - - - - {T.translate("edit_view_type.description")} - - - - - - } - label={T.translate("edit_view_type.is_default")} - /> + + + + + + + + + {T.translate("edit_view_type.description")} + + + + + + - - - - - + + + + + ); }; diff --git a/src/pages/badges/edit-view-type-page.js b/src/pages/badges/edit-view-type-page.js index c5a66590d..e194e9ef8 100644 --- a/src/pages/badges/edit-view-type-page.js +++ b/src/pages/badges/edit-view-type-page.js @@ -21,7 +21,7 @@ import { resetViewTypeForm, saveViewType } from "../../actions/badge-actions"; -import AddNewButtonMUI from "../../components/buttons/add-new-button-mui"; +import AddNewButton from "../../components/buttons/add-new-button"; const EditViewTypePage = ({ currentSummit, @@ -51,7 +51,7 @@ const EditViewTypePage = ({

{title} {T.translate("edit_view_type.view_type")} - +


{currentSummit && ( From ad0085c905b7ee8ba5c07e2148a8771237507c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Tue, 13 Oct 2026 02:35:28 -0300 Subject: [PATCH 4/9] fix: update new button with mui components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/pages/badges/edit-view-type-page.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/pages/badges/edit-view-type-page.js b/src/pages/badges/edit-view-type-page.js index e194e9ef8..e2a5a82f7 100644 --- a/src/pages/badges/edit-view-type-page.js +++ b/src/pages/badges/edit-view-type-page.js @@ -21,13 +21,14 @@ import { resetViewTypeForm, saveViewType } from "../../actions/badge-actions"; -import AddNewButton from "../../components/buttons/add-new-button"; +import AddNewButton from "../../components/buttons/add-new-button-mui"; const EditViewTypePage = ({ currentSummit, entity, errors, match, + history, getViewType, resetViewTypeForm, saveViewType @@ -51,7 +52,13 @@ const EditViewTypePage = ({

{title} {T.translate("edit_view_type.view_type")} - + {entity.id > 0 && ( + + history.push(`/app/summits/${currentSummit.id}/view-types/new`) + } + /> + )}


{currentSummit && ( From 08abd85a4b61fa00bebe58923ca03c8f3de39ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 11 Nov 2026 01:33:00 -0300 Subject: [PATCH 5/9] fix: update uicore version and use mui add new button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/buttons/add-new-button-mui.js | 22 -------------------- src/pages/badges/edit-view-type-page.js | 2 +- 2 files changed, 1 insertion(+), 23 deletions(-) delete mode 100644 src/components/buttons/add-new-button-mui.js diff --git a/src/components/buttons/add-new-button-mui.js b/src/components/buttons/add-new-button-mui.js deleted file mode 100644 index ecc3511d0..000000000 --- a/src/components/buttons/add-new-button-mui.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import Button from "@mui/material/Button"; -import AddIcon from "@mui/icons-material/Add"; -import T from "i18n-react"; - -const AddNewButton = ({ onClick }) => ( - -); - -AddNewButton.propTypes = { - onClick: PropTypes.func.isRequired -}; - -export default AddNewButton; diff --git a/src/pages/badges/edit-view-type-page.js b/src/pages/badges/edit-view-type-page.js index e2a5a82f7..95e9e1734 100644 --- a/src/pages/badges/edit-view-type-page.js +++ b/src/pages/badges/edit-view-type-page.js @@ -15,13 +15,13 @@ import React, { useEffect } from "react"; import { connect } from "react-redux"; import { Breadcrumb } from "react-breadcrumbs"; import T from "i18n-react/dist/i18n-react"; +import AddNewButton from "openstack-uicore-foundation/lib/components/mui/add-new-button"; import ViewTypeForm from "../../components/forms/view-type-form"; import { getViewType, resetViewTypeForm, saveViewType } from "../../actions/badge-actions"; -import AddNewButton from "../../components/buttons/add-new-button-mui"; const EditViewTypePage = ({ currentSummit, From 3a3486c103ea84f9cfe74df2be77b2f8b0118f89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Wed, 11 Nov 2026 23:47:23 -0300 Subject: [PATCH 6/9] fix: adjust return in actions, update validations based on API rules, disable save button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/badge-actions.js | 47 +++++++++---------- .../forms/__tests__/view-type-form.test.js | 31 ++++++++---- src/components/forms/view-type-form.js | 40 +++++++++++----- src/i18n/en.json | 2 + 4 files changed, 76 insertions(+), 44 deletions(-) diff --git a/src/actions/badge-actions.js b/src/actions/badge-actions.js index b8ec95b95..fbd81106a 100644 --- a/src/actions/badge-actions.js +++ b/src/actions/badge-actions.js @@ -529,6 +529,7 @@ export const resetViewTypeForm = () => (dispatch) => { export const saveViewType = (entity) => async (dispatch, getState) => { const { currentSummitState } = getState(); + dispatch(startLoading()); const accessToken = await getAccessTokenSafely(); const { currentSummit } = currentSummitState; @@ -536,12 +537,10 @@ export const saveViewType = (entity) => async (dispatch, getState) => { access_token: accessToken }; - dispatch(startLoading()); - const normalizedEntity = normalizeViewType(entity); if (entity.id) { - putRequest( + return putRequest( createAction(UPDATE_VIEW_TYPE), createAction(VIEW_TYPE_UPDATED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/badge-view-types/${entity.id}`, @@ -558,28 +557,28 @@ export const saveViewType = (entity) => async (dispatch, getState) => { }) .catch(() => {}) .finally(() => dispatch(stopLoading())); - } else { - postRequest( - createAction(UPDATE_VIEW_TYPE), - createAction(VIEW_TYPE_ADDED), - `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/badge-view-types`, - normalizedEntity, - snackbarErrorHandler, - entity - )(params)(dispatch) - .then((payload) => { - dispatch( - snackbarSuccessHandler({ - html: T.translate("edit_view_type.view_type_created") - }) - ); - history.push( - `/app/summits/${currentSummit.id}/view-types/${payload.response.id}` - ); - }) - .catch(() => {}) - .finally(() => dispatch(stopLoading())); } + + return postRequest( + createAction(UPDATE_VIEW_TYPE), + createAction(VIEW_TYPE_ADDED), + `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/badge-view-types`, + normalizedEntity, + snackbarErrorHandler, + entity + )(params)(dispatch) + .then((payload) => { + dispatch( + snackbarSuccessHandler({ + html: T.translate("edit_view_type.view_type_created") + }) + ); + history.push( + `/app/summits/${currentSummit.id}/view-types/${payload.response.id}` + ); + }) + .catch(() => {}) + .finally(() => dispatch(stopLoading())); }; export const deleteViewType = (viewTypeId) => async (dispatch, getState) => { diff --git a/src/components/forms/__tests__/view-type-form.test.js b/src/components/forms/__tests__/view-type-form.test.js index 8345b9917..ef49d8156 100644 --- a/src/components/forms/__tests__/view-type-form.test.js +++ b/src/components/forms/__tests__/view-type-form.test.js @@ -13,7 +13,11 @@ jest.mock( "openstack-uicore-foundation/lib/components/mui/formik-inputs/texteditor", () => ({ __esModule: true, - default: () => null + default: ({ name }) => { + // eslint-disable-next-line global-require + const { useFormikContext } = require("formik"); + return useFormikContext().errors[name] ?? null; + } }) ); @@ -25,8 +29,8 @@ jest.mock("../../../hooks/useScrollToError", () => ({ const ENTITY = { id: 3, name: "Card", description: "", is_default: null }; describe("ViewTypeForm", () => { - it("shows server errors and submits the edited entity", async () => { - const onSubmit = jest.fn(); + it("shows server errors and submits the edited entity once while saving", async () => { + const onSubmit = jest.fn(() => new Promise(() => {})); render( { await userEvent.type( screen.getByRole("textbox", { name: /edit_view_type.name/ }), - " Print" + "Print" ); await userEvent.click(screen.getByRole("checkbox")); await userEvent.click(screen.getByRole("button", { name: "general.save" })); @@ -47,21 +51,32 @@ describe("ViewTypeForm", () => { await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ ...ENTITY, - name: "Card Print", + name: "CardPrint", is_default: true }) ); + expect(screen.getByRole("button", { name: "general.save" })).toBeDisabled(); + expect(onSubmit).toHaveBeenCalledTimes(1); }); - it("blocks submit when name is empty", async () => { + it("blocks submit on values the API rejects", async () => { const onSubmit = jest.fn(); - render( - + const { rerender } = render( + ); await userEvent.click(screen.getByRole("button", { name: "general.save" })); + expect( + await screen.findByText("validation.single_word") + ).toBeInTheDocument(); + rerender(); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); expect(await screen.findByText("validation.required")).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); }); }); diff --git a/src/components/forms/view-type-form.js b/src/components/forms/view-type-form.js index e2f3c86b5..50c0fea78 100644 --- a/src/components/forms/view-type-form.js +++ b/src/components/forms/view-type-form.js @@ -11,7 +11,7 @@ * limitations under the License. * */ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import PropTypes from "prop-types"; import { useFormik, FormikProvider } from "formik"; import * as yup from "yup"; @@ -25,22 +25,42 @@ import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/f import MuiFormikCheckbox from "openstack-uicore-foundation/lib/components/mui/formik-inputs/checkbox"; import FormikTextEditor from "openstack-uicore-foundation/lib/components/mui/formik-inputs/texteditor"; import useScrollToError from "../../hooks/useScrollToError"; -import { requiredStringValidation } from "../../utils/yup"; +import { + requiredHTMLValidation, + requiredStringValidation +} from "../../utils/yup"; -const validationSchema = yup.object({ - name: requiredStringValidation() -}); +const NAME_MAX_LENGTH = 50; + +const buildValidationSchema = (entity) => + yup.object({ + name: requiredStringValidation() + .matches(/^\S+$/, T.translate("validation.single_word")) + .max( + NAME_MAX_LENGTH, + T.translate("validation.max_length", { max: NAME_MAX_LENGTH }) + ), + description: entity.id ? yup.string() : requiredHTMLValidation() + }); const ViewTypeForm = ({ entity, errors, onSubmit }) => { + const [isSaving, setIsSaving] = useState(false); + const formik = useFormik({ initialValues: { name: entity.name || "", description: entity.description || "", is_default: !!entity.is_default }, - validationSchema, + validationSchema: buildValidationSchema(!entity.id), enableReinitialize: true, - onSubmit: (values) => onSubmit({ ...entity, ...values }) + onSubmit: (values) => { + if (isSaving) return; + setIsSaving(true); + Promise.resolve(onSubmit({ ...entity, ...values })).finally(() => + setIsSaving(false) + ); + } }); useScrollToError(formik); @@ -88,11 +108,7 @@ const ViewTypeForm = ({ entity, errors, onSubmit }) => { - diff --git a/src/i18n/en.json b/src/i18n/en.json index e437f28d2..d8ecba432 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -124,6 +124,8 @@ "maximum": "Must be at most {maximum}", "time": "Wrong time format.", "wrong_format": "Wrong format.", + "single_word": "Must be a single word.", + "max_length": "Must be at most {max} characters.", "add_on_required": "Select at least one add-on", "additional_items": "Unable to save due to missing required additional info — click the ⚙ icon to review", "extra_question_value": "This question type requires at least one value." From a04126e601e3059fcf3de07260678f539a66341a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 12 Nov 2026 00:45:07 -0300 Subject: [PATCH 7/9] fix: pass entity on buildValidationSchema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/forms/view-type-form.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/forms/view-type-form.js b/src/components/forms/view-type-form.js index 50c0fea78..956ca5f9d 100644 --- a/src/components/forms/view-type-form.js +++ b/src/components/forms/view-type-form.js @@ -52,7 +52,7 @@ const ViewTypeForm = ({ entity, errors, onSubmit }) => { description: entity.description || "", is_default: !!entity.is_default }, - validationSchema: buildValidationSchema(!entity.id), + validationSchema: buildValidationSchema(entity), enableReinitialize: true, onSubmit: (values) => { if (isSaving) return; From e57b251817e1feca9f8293f95cd6ad385bdb7ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Thu, 12 Nov 2026 01:23:29 -0300 Subject: [PATCH 8/9] fix: adjust action order for getViewType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/actions/badge-actions.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/actions/badge-actions.js b/src/actions/badge-actions.js index fbd81106a..cf501fd41 100644 --- a/src/actions/badge-actions.js +++ b/src/actions/badge-actions.js @@ -504,11 +504,10 @@ export const getViewTypes = export const getViewType = (viewTypeId) => async (dispatch, getState) => { const { currentSummitState } = getState(); + dispatch(startLoading()); const accessToken = await getAccessTokenSafely(); const { currentSummit } = currentSummitState; - dispatch(startLoading()); - const params = { access_token: accessToken }; From 931839488440d5b364e1932fbedc00835fad70db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Castillo?= Date: Fri, 13 Nov 2026 14:00:59 -0300 Subject: [PATCH 9/9] fix: add character validation for description, update unit test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Castillo --- src/components/forms/__tests__/view-type-form.test.js | 11 +++++++++++ src/components/forms/view-type-form.js | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/forms/__tests__/view-type-form.test.js b/src/components/forms/__tests__/view-type-form.test.js index ef49d8156..cc70f158a 100644 --- a/src/components/forms/__tests__/view-type-form.test.js +++ b/src/components/forms/__tests__/view-type-form.test.js @@ -77,6 +77,17 @@ describe("ViewTypeForm", () => { await userEvent.click(screen.getByRole("button", { name: "general.save" })); expect(await screen.findByText("validation.required")).toBeInTheDocument(); + rerender( + ${"a".repeat(250)}

` }} + onSubmit={onSubmit} + /> + ); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + expect( + await screen.findByText("validation.max_length") + ).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); }); }); diff --git a/src/components/forms/view-type-form.js b/src/components/forms/view-type-form.js index 956ca5f9d..7e8340dff 100644 --- a/src/components/forms/view-type-form.js +++ b/src/components/forms/view-type-form.js @@ -31,6 +31,7 @@ import { } from "../../utils/yup"; const NAME_MAX_LENGTH = 50; +const DESCRIPTION_MAX_LENGTH = 255; const buildValidationSchema = (entity) => yup.object({ @@ -40,7 +41,10 @@ const buildValidationSchema = (entity) => NAME_MAX_LENGTH, T.translate("validation.max_length", { max: NAME_MAX_LENGTH }) ), - description: entity.id ? yup.string() : requiredHTMLValidation() + description: (entity.id ? yup.string() : requiredHTMLValidation()).max( + DESCRIPTION_MAX_LENGTH, + T.translate("validation.max_length", { max: DESCRIPTION_MAX_LENGTH }) + ) }); const ViewTypeForm = ({ entity, errors, onSubmit }) => {