1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-16 22:50:10 +03:00

F OpenNebula/one#6662: Enhance form validation reporting (#3179)

Signed-off-by: Victor Hansson <vhansson@opennebula.io>
This commit is contained in:
vichansson 2024-07-30 17:14:14 +03:00 committed by GitHub
parent abe1818b10
commit 8e67442fe0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 165 additions and 62 deletions

View File

@ -15,18 +15,28 @@
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import { Box, Button, Typography } from '@mui/material'
import {
Box,
Button,
Typography,
List,
ListItem,
ListItemText,
Paper,
IconButton,
Popover,
} from '@mui/material'
import Step from '@mui/material/Step'
import { NavArrowDown, NavArrowUp } from 'iconoir-react'
import StepButton from '@mui/material/StepButton'
import StepConnector, {
stepConnectorClasses,
} from '@mui/material/StepConnector'
import { useState } from 'react'
import StepIcon, { stepIconClasses } from '@mui/material/StepIcon'
import StepLabel from '@mui/material/StepLabel'
import Stepper from '@mui/material/Stepper'
import { styled } from '@mui/styles'
import { SubmitButton } from 'client/components/FormControl'
import { Translate } from 'client/components/HOC'
import { SCHEMES, T } from 'client/constants'
@ -66,6 +76,25 @@ const ConnectorStyled = styled(StepConnector)(({ theme }) => ({
},
}))
const ErrorListContainer = styled(Paper)(({ theme }) => ({
maxHeight: 200,
overflowY: 'auto',
padding: theme.spacing(1),
backgroundColor: theme.palette.background.default,
}))
const ErrorSummaryContainer = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
padding: theme.spacing(1),
borderRadius: theme.shape.borderRadius,
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
}))
const StepIconStyled = styled(StepIcon)(({ theme }) => ({
color: theme.palette.text.hint,
display: 'block',
@ -97,57 +126,117 @@ const CustomStepper = ({
handleBack,
errors,
isSubmitting,
}) => (
<>
<StepperStyled
nonLinear
activeStep={activeStep}
connector={<ConnectorStyled />}
>
{steps?.map(({ id, label }, stepIdx) => (
<Step key={id} completed={activeStep > stepIdx}>
<StepButton
onClick={() => handleStep(stepIdx)}
disabled={activeStep + 1 < stepIdx}
optional={
errors[id] && (
<Typography variant="caption" color="error">
<Translate word={errors[id]?.message} />
</Typography>
)
}
data-cy={`step-${id}`}
>
<StepLabel
StepIconComponent={StepIconStyled}
error={Boolean(errors[id]?.message)}
>
<Translate word={label} />
</StepLabel>
</StepButton>
</Step>
))}
</StepperStyled>
<ButtonsWrapper>
<Button
data-cy="stepper-back-button"
disabled={disabledBack || isSubmitting}
onClick={handleBack}
size="small"
}) => {
const [anchorEl, setAnchorEl] = useState(null)
const [currentStep, setCurrentStep] = useState(null)
const handleClick = (event, stepIdx) => {
setAnchorEl(event.currentTarget)
setCurrentStep(stepIdx)
}
const handleClose = () => {
setAnchorEl(null)
setCurrentStep(null)
}
const open = Boolean(anchorEl)
return (
<>
<StepperStyled
nonLinear
activeStep={activeStep}
connector={<ConnectorStyled />}
>
<Translate word={T.Back} />
</Button>
<SubmitButton
color="secondary"
data-cy="stepper-next-button"
isSubmitting={isSubmitting}
onClick={handleNext}
size="small"
label={<Translate word={activeStep === lastStep ? T.Finish : T.Next} />}
/>
</ButtonsWrapper>
</>
)
{steps?.map(({ id, label }, stepIdx) => {
const errorData = errors[id]
const hasError = Boolean(errorData?.message)
const individualMessages = errorData?.individualErrorMessages || []
return (
<Step key={id} completed={activeStep > stepIdx}>
<StepButton
onClick={() => handleStep(stepIdx)}
disabled={activeStep + 1 < stepIdx}
data-cy={`step-${id}`}
>
<StepLabel StepIconComponent={StepIconStyled} error={hasError}>
<Translate word={label} />
</StepLabel>
</StepButton>
{hasError && (
<>
<ErrorSummaryContainer
onClick={(event) => handleClick(event, stepIdx)}
>
<Typography variant="caption" color="error">
{`${errorData.message[0].replace(
'%s',
errorData.message[1]
)}`}
</Typography>
<IconButton size="small">
{currentStep === stepIdx && open ? (
<NavArrowUp />
) : (
<NavArrowDown />
)}
</IconButton>
</ErrorSummaryContainer>
<Popover
id={id}
open={currentStep === stepIdx && open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
>
<ErrorListContainer>
<List>
{individualMessages.flat().map((msg, index) => (
<ListItem key={index}>
<ListItemText primary={msg} />
</ListItem>
))}
</List>
</ErrorListContainer>
</Popover>
</>
)}
</Step>
)
})}
</StepperStyled>
<ButtonsWrapper>
<Button
data-cy="stepper-back-button"
disabled={disabledBack || isSubmitting}
onClick={handleBack}
size="small"
>
<Translate word={T.Back} />
</Button>
<SubmitButton
color="secondary"
data-cy="stepper-next-button"
isSubmitting={isSubmitting}
onClick={handleNext}
size="small"
label={
<Translate word={activeStep === lastStep ? T.Finish : T.Next} />
}
/>
</ButtonsWrapper>
</>
)
}
CustomStepper.propTypes = {
steps: PropTypes.arrayOf(

View File

@ -208,14 +208,28 @@ const FormStepper = ({
const setErrors = ({ inner = [], message = { word: 'Error' } } = {}) => {
const errorsByPath = groupBy(inner, 'path') ?? {}
const totalErrors = Object.keys(errorsByPath).length
const totalErrors = Object.values(errorsByPath).reduce((count, value) => {
if (Array.isArray(value)) {
const filteredValue = value?.filter(Boolean) || []
return count + filteredValue?.length || 0
}
return count
}, 0)
const translationError =
totalErrors > 0 ? [T.ErrorsOcurred, totalErrors] : Object.values(message)
setError(stepId, { type: 'manual', message: translationError })
const individualErrorMessages = inner.map((error) => error?.message ?? '')
inner?.forEach(({ path, type, errors: innerMessage }) => {
setError(stepId, {
type: 'manual',
message: translationError,
individualErrorMessages,
})
inner?.forEach(({ path, type, errors: innerMessage }, index) => {
setError(`${stepId}.${path}`, { type, message: innerMessage })
})
}

View File

@ -40,9 +40,9 @@ const RoleVmVmPanel = ({ roles, onChange, selectedRoleIndex }) => {
onChange(updatedRole)
}
const handleTextFieldChange = (event) => {
const handleTextFieldChange = (event, number = false) => {
const { name, value } = event.target
handleInputChange(name, parseInt(value, 10))
handleInputChange(name, number ? parseInt(value, 10) : value)
}
const handleAutocompleteChange = (event, value) => {
@ -79,7 +79,7 @@ const RoleVmVmPanel = ({ roles, onChange, selectedRoleIndex }) => {
label={Tr(T.NumberOfVms)}
name="CARDINALITY"
value={selectedRole?.CARDINALITY || 0}
onChange={handleTextFieldChange}
onChange={(event) => handleTextFieldChange(event, true)}
disabled={isDisabled}
InputProps={{
inputProps: {

View File

@ -99,9 +99,9 @@ const buildTranslationLocale = () => {
setLocale({
mixed: {
default: () => T['validation.mixed.default'],
required: () => T['validation.mixed.required'],
defined: () => T['validation.mixed.defined'],
default: ({ path }) => `${path} ${T['validation.mixed.default']}`,
required: ({ path }) => `${path} ${T['validation.mixed.required']}`,
defined: ({ path }) => `${path} ${T['validation.mixed.defined']}`,
oneOf: ({ values }) => ({ word: T['validation.mixed.oneOf'], values }),
notOneOf: ({ values }) => ({
word: T['validation.mixed.notOneOf'],