diff --git a/packages/web/src/components/Container/index.jsx b/packages/web/src/components/Container/index.jsx index ffafaa14..ef75335b 100644 --- a/packages/web/src/components/Container/index.jsx +++ b/packages/web/src/components/Container/index.jsx @@ -1,10 +1,19 @@ import * as React from 'react'; +import PropTypes from 'prop-types'; import MuiContainer from '@mui/material/Container'; -export default function Container(props) { - return ; +export default function Container({ maxWidth = 'lg', ...props }) { + return ; } -Container.defaultProps = { - maxWidth: 'lg', +Container.propTypes = { + maxWidth: PropTypes.oneOf([ + 'xs', + 'sm', + 'md', + 'lg', + 'xl', + false, + PropTypes.string, + ]), }; diff --git a/packages/web/src/components/Form/index.jsx b/packages/web/src/components/Form/index.jsx index 614873f7..352b0a69 100644 --- a/packages/web/src/components/Form/index.jsx +++ b/packages/web/src/components/Form/index.jsx @@ -46,7 +46,12 @@ function Form(props) { return ( -
+ + onSubmit?.(data, event, methods.setError), + )} + {...formProps} + > {render ? render(methods) : children}
diff --git a/packages/web/src/helpers/errors.js b/packages/web/src/helpers/errors.js new file mode 100644 index 00000000..dc73867d --- /dev/null +++ b/packages/web/src/helpers/errors.js @@ -0,0 +1,18 @@ +// Helpers to extract errors received from the API + +export const getGeneralErrorMessage = ({ error, fallbackMessage }) => { + if (!error) { + return; + } + + const errors = error?.response?.data?.errors; + const generalError = errors?.general; + + if (generalError && Array.isArray(generalError)) { + return generalError.join(' '); + } + + if (!errors) { + return error?.message || fallbackMessage; + } +}; diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index b121f5e2..ae48b0b5 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -225,6 +225,8 @@ "userForm.email": "Email", "userForm.role": "Role", "userForm.password": "Password", + "userForm.mandatoryInput": "{inputName} is required.", + "userForm.validateEmail": "Email must be valid.", "createUser.submit": "Create", "createUser.successfullyCreated": "The user has been created.", "createUser.invitationEmailInfo": "Invitation email will be sent if SMTP credentials are valid. Otherwise, you can share the invitation link manually: ", @@ -249,8 +251,11 @@ "createRolePage.title": "Create role", "roleForm.name": "Name", "roleForm.description": "Description", + "roleForm.mandatoryInput": "{inputName} is required.", "createRole.submit": "Create", "createRole.successfullyCreated": "The role has been created.", + "createRole.generalError": "Error while creating the role.", + "createRole.permissionsError": "Permissions are invalid.", "editRole.submit": "Update", "editRole.successfullyUpdated": "The role has been updated.", "roleList.name": "Name", diff --git a/packages/web/src/pages/CreateRole/index.ee.jsx b/packages/web/src/pages/CreateRole/index.ee.jsx index b5ff22c9..2b44bbe0 100644 --- a/packages/web/src/pages/CreateRole/index.ee.jsx +++ b/packages/web/src/pages/CreateRole/index.ee.jsx @@ -1,10 +1,14 @@ import LoadingButton from '@mui/lab/LoadingButton'; import Grid from '@mui/material/Grid'; import Stack from '@mui/material/Stack'; +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; import PermissionCatalogField from 'components/PermissionCatalogField/index.ee'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import * as React from 'react'; import { useNavigate } from 'react-router-dom'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; import Container from 'components/Container'; import Form from 'components/Form'; @@ -15,10 +19,45 @@ import { getComputedPermissionsDefaultValues, getPermissions, } from 'helpers/computePermissions.ee'; +import { getGeneralErrorMessage } from 'helpers/errors'; import useFormatMessage from 'hooks/useFormatMessage'; import useAdminCreateRole from 'hooks/useAdminCreateRole'; import usePermissionCatalog from 'hooks/usePermissionCatalog.ee'; +const getValidationSchema = (formatMessage) => { + const getMandatoryFieldMessage = (fieldTranslationId) => + formatMessage('roleForm.mandatoryInput', { + inputName: formatMessage(fieldTranslationId), + }); + + return yup.object().shape({ + name: yup + .string() + .trim() + .required(getMandatoryFieldMessage('roleForm.name')), + description: yup.string().trim(), + }); +}; + +const getPermissionsErrorMessage = (error) => { + const errors = error?.response?.data?.errors; + + if (errors) { + const permissionsErrors = Object.keys(errors) + .filter((key) => key.startsWith('permissions')) + .reduce((obj, key) => { + obj[key] = errors[key]; + return obj; + }, {}); + + if (Object.keys(permissionsErrors).length > 0) { + return JSON.stringify(permissionsErrors, null, 2); + } + } + + return null; +}; + export default function CreateRole() { const navigate = useNavigate(); const formatMessage = useFormatMessage(); @@ -41,7 +80,7 @@ export default function CreateRole() { [permissionCatalogData], ); - const handleRoleCreation = async (roleData) => { + const handleRoleCreation = async (roleData, e, setError) => { try { const permissions = getPermissions(roleData.computedPermissions); @@ -60,14 +99,38 @@ export default function CreateRole() { navigate(URLS.ROLES); } catch (error) { - const errors = Object.values(error.response.data.errors); + const errors = error?.response?.data?.errors; - for (const [errorMessage] of errors) { - enqueueSnackbar(errorMessage, { - variant: 'error', - SnackbarProps: { - 'data-test': 'snackbar-error', - }, + if (errors) { + const fieldNames = ['name', 'description']; + Object.entries(errors).forEach(([fieldName, fieldErrors]) => { + if (fieldNames.includes(fieldName) && Array.isArray(fieldErrors)) { + setError(fieldName, { + type: 'fieldRequestError', + message: fieldErrors.join(', '), + }); + } + }); + } + + const permissionError = getPermissionsErrorMessage(error); + + if (permissionError) { + setError('root.permissions', { + type: 'fieldRequestError', + message: permissionError, + }); + } + + const generalError = getGeneralErrorMessage({ + error, + fallbackMessage: formatMessage('createRole.generalError'), + }); + + if (generalError) { + setError('root.general', { + type: 'requestError', + message: generalError, }); } } @@ -83,37 +146,67 @@ export default function CreateRole() { -
- - + ( + + - + - + - - {formatMessage('createRole.submit')} - - - + {errors?.root?.permissions && ( + + + {formatMessage('createRole.permissionsError')} + +
+                      {errors?.root?.permissions?.message}
+                    
+
+ )} + + {errors?.root?.general && ( + + {errors?.root?.general?.message} + + )} + + + {formatMessage('createRole.submit')} + +
+ )} + />
diff --git a/packages/web/src/pages/CreateUser/index.jsx b/packages/web/src/pages/CreateUser/index.jsx index ad96ba96..689791e5 100644 --- a/packages/web/src/pages/CreateUser/index.jsx +++ b/packages/web/src/pages/CreateUser/index.jsx @@ -3,9 +3,10 @@ import Grid from '@mui/material/Grid'; import Stack from '@mui/material/Stack'; import Alert from '@mui/material/Alert'; import MuiTextField from '@mui/material/TextField'; -import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import * as React from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; import Can from 'components/Can'; import Container from 'components/Container'; @@ -16,50 +17,94 @@ import TextField from 'components/TextField'; import useFormatMessage from 'hooks/useFormatMessage'; import useRoles from 'hooks/useRoles.ee'; import useAdminCreateUser from 'hooks/useAdminCreateUser'; +import useCurrentUserAbility from 'hooks/useCurrentUserAbility'; +import { getGeneralErrorMessage } from 'helpers/errors'; function generateRoleOptions(roles) { return roles?.map(({ name: label, id: value }) => ({ label, value })); } +const getValidationSchema = (formatMessage, canUpdateRole) => { + const getMandatoryFieldMessage = (fieldTranslationId) => + formatMessage('userForm.mandatoryInput', { + inputName: formatMessage(fieldTranslationId), + }); + + return yup.object().shape({ + fullName: yup + .string() + .trim() + .required(getMandatoryFieldMessage('userForm.fullName')), + email: yup + .string() + .trim() + .email(formatMessage('userForm.validateEmail')) + .required(getMandatoryFieldMessage('userForm.email')), + ...(canUpdateRole + ? { + roleId: yup + .string() + .required(getMandatoryFieldMessage('userForm.role')), + } + : {}), + }); +}; + +const defaultValues = { + fullName: '', + email: '', + roleId: '', +}; + export default function CreateUser() { const formatMessage = useFormatMessage(); const { mutateAsync: createUser, isPending: isCreateUserPending, data: createdUser, + isSuccess: createUserSuccess, } = useAdminCreateUser(); const { data: rolesData, loading: isRolesLoading } = useRoles(); const roles = rolesData?.data; - const enqueueSnackbar = useEnqueueSnackbar(); const queryClient = useQueryClient(); + const currentUserAbility = useCurrentUserAbility(); + const canUpdateRole = currentUserAbility.can('update', 'Role'); - const handleUserCreation = async (userData) => { + const handleUserCreation = async (userData, e, setError) => { try { await createUser({ fullName: userData.fullName, email: userData.email, - roleId: userData.role?.id, + roleId: userData.roleId, }); queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); - - enqueueSnackbar(formatMessage('createUser.successfullyCreated'), { - variant: 'success', - persist: true, - SnackbarProps: { - 'data-test': 'snackbar-create-user-success', - }, - }); } catch (error) { - enqueueSnackbar(formatMessage('createUser.error'), { - variant: 'error', - persist: true, - SnackbarProps: { - 'data-test': 'snackbar-error', - }, + const errors = error?.response?.data?.errors; + + if (errors) { + const fieldNames = Object.keys(defaultValues); + Object.entries(errors).forEach(([fieldName, fieldErrors]) => { + if (fieldNames.includes(fieldName) && Array.isArray(fieldErrors)) { + setError(fieldName, { + type: 'fieldRequestError', + message: fieldErrors.join(', '), + }); + } + }); + } + + const generalError = getGeneralErrorMessage({ + error, + fallbackMessage: formatMessage('createUser.error'), }); - throw new Error('Failed while creating!'); + if (generalError) { + setError('root.general', { + type: 'requestError', + message: generalError, + }); + } } }; @@ -73,74 +118,111 @@ export default function CreateUser() { -
- - - - - - - ( + + ( - - )} - loading={isRolesLoading} + error={!!errors?.fullName} + helperText={errors?.fullName?.message} /> - - - {formatMessage('createUser.submit')} - + - {createdUser && ( - + ( + + )} + loading={isRolesLoading} + showHelperText={false} + /> + + + {errors?.root?.general && ( + + {errors?.root?.general?.message} + + )} + + {createUserSuccess && ( + + {formatMessage('createUser.successfullyCreated')} + + )} + + {createdUser && ( + + {formatMessage('createUser.invitationEmailInfo', { + link: () => ( + + {createdUser.data.acceptInvitationUrl} + + ), + })} + + )} + + - {formatMessage('createUser.invitationEmailInfo', { - link: () => ( - - {createdUser.data.acceptInvitationUrl} - - ), - })} - - )} - -
+ {formatMessage('createUser.submit')} + + + )} + />