diff --git a/src/actions/badge-actions.js b/src/actions/badge-actions.js index 79a09182d..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 }; @@ -520,9 +519,7 @@ export const getViewType = (viewTypeId) => async (dispatch, getState) => { snackbarErrorHandler )(params)(dispatch) .catch(() => {}) - .finally(() => { - dispatch(stopLoading()); - }); + .finally(() => dispatch(stopLoading())); }; export const resetViewTypeForm = () => (dispatch) => { @@ -531,6 +528,7 @@ export const resetViewTypeForm = () => (dispatch) => { export const saveViewType = (entity) => async (dispatch, getState) => { const { currentSummitState } = getState(); + dispatch(startLoading()); const accessToken = await getAccessTokenSafely(); const { currentSummit } = currentSummitState; @@ -538,47 +536,48 @@ 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}`, normalizedEntity, snackbarErrorHandler, entity - )(params)(dispatch).then(() => { - dispatch( - showSuccessMessage(T.translate("edit_view_type.view_type_saved")) - ); - }); - } else { - const success_message = { - title: T.translate("general.done"), - html: T.translate("edit_view_type.view_type_created"), - type: "success" - }; + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + html: T.translate("edit_view_type.view_type_saved") + }) + ); + }) + .catch(() => {}) + .finally(() => dispatch(stopLoading())); + } - 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) => { + 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( - showMessage(success_message, () => { - history.push( - `/app/summits/${currentSummit.id}/view-types/${payload.response.id}` - ); + 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 new file mode 100644 index 000000000..cc70f158a --- /dev/null +++ b/src/components/forms/__tests__/view-type-form.test.js @@ -0,0 +1,93 @@ +import React from "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"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/mui/formik-inputs/texteditor", + () => ({ + __esModule: true, + default: ({ name }) => { + // eslint-disable-next-line global-require + const { useFormikContext } = require("formik"); + return useFormikContext().errors[name] ?? null; + } + }) +); + +jest.mock("../../../hooks/useScrollToError", () => ({ + __esModule: true, + default: jest.fn() +})); + +const ENTITY = { id: 3, name: "Card", description: "", is_default: null }; + +describe("ViewTypeForm", () => { + it("shows server errors and submits the edited entity once while saving", async () => { + const onSubmit = jest.fn(() => new Promise(() => {})); + render( + + ); + + 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" })); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + ...ENTITY, + name: "CardPrint", + is_default: true + }) + ); + expect(screen.getByRole("button", { name: "general.save" })).toBeDisabled(); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it("blocks submit on values the API rejects", async () => { + const onSubmit = jest.fn(); + 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(); + + 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 3f25f12f3..7e8340dff 100644 --- a/src/components/forms/view-type-form.js +++ b/src/components/forms/view-type-form.js @@ -11,129 +11,124 @@ * 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 TextEditorV3 from "openstack-uicore-foundation/lib/components/inputs/editor-input-v3"; +import React, { useEffect, useState } 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 Grid2 from "@mui/material/Grid2"; +import InputLabel from "@mui/material/InputLabel"; +import Stack from "@mui/material/Stack"; +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 { - hasErrors, - isEmpty, - scrollToError, - shallowEqual -} from "../../utils/methods"; - -class ViewTypeForm extends React.Component { - constructor(props) { - super(props); - - this.state = { - entity: { ...props.entity }, - errors: props.errors - }; - - this.handleChange = this.handleChange.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - } - - componentDidUpdate(prevProps) { - const state = {}; - scrollToError(this.props.errors); - - 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 }); + requiredHTMLValidation, + requiredStringValidation +} from "../../utils/yup"; + +const NAME_MAX_LENGTH = 50; +const DESCRIPTION_MAX_LENGTH = 255; + +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()).max( + DESCRIPTION_MAX_LENGTH, + T.translate("validation.max_length", { max: DESCRIPTION_MAX_LENGTH }) + ) + }); + +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: buildValidationSchema(entity), + enableReinitialize: true, + onSubmit: (values) => { + if (isSaving) return; + setIsSaving(true); + Promise.resolve(onSubmit({ ...entity, ...values })).finally(() => + setIsSaving(false) + ); } - } - - 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; + }); + + useScrollToError(formik); + + 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[id] = ""; - entity[id] = value; - this.setState({ entity, errors }); - } - - handleSubmit(ev) { - ev.preventDefault(); - this.props.onSubmit(this.state.entity); - } - - render() { - const { entity, errors } = this.state; - - return ( -
- -
-
- - -
-
-
-
- - + + + + -
-
-
-
-
- - -
-
-
- -
- -
-
- + + + {T.translate("edit_view_type.description")} + + + + + -
-
-
- ); - } -} + + + + + + + + + ); +}; + +ViewTypeForm.propTypes = { + entity: PropTypes.object.isRequired, + errors: PropTypes.object, + onSubmit: PropTypes.func.isRequired +}; + +ViewTypeForm.defaultProps = { + errors: {} +}; export default ViewTypeForm; 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." diff --git a/src/pages/badges/edit-view-type-page.js b/src/pages/badges/edit-view-type-page.js index 4cef64b89..95e9e1734 100644 --- a/src/pages/badges/edit-view-type-page.js +++ b/src/pages/badges/edit-view-type-page.js @@ -11,92 +11,62 @@ * 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 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"; -class EditViewPagePage extends React.Component { - constructor(props) { - const viewTypeId = props.match.params.view_type_id; - super(props); +const EditViewTypePage = ({ + currentSummit, + entity, + errors, + match, + history, + 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 && ( - + +

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

- ); - } -} + +
+ {currentSummit && ( + + )} + + ); +}; const mapStateToProps = ({ currentSummitState, currentViewTypeState }) => ({ currentSummit: currentSummitState.currentSummit, @@ -104,8 +74,7 @@ const mapStateToProps = ({ currentSummitState, currentViewTypeState }) => ({ }); export default connect(mapStateToProps, { - getSummitById, getViewType, resetViewTypeForm, saveViewType -})(EditViewPagePage); +})(EditViewTypePage);