1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-04 13:48:56 +02:00

feat: improve role form validation (#5548)

https://linear.app/unleash/issue/2-1717/improve-the-ux-when-all-the-required-fields-are-not-filled-in

Improves role form validation behavior.
We may want to look into a form validation library, like
[react-hook-form](https://react-hook-form.com/), for future
implementations.
This commit is contained in:
Nuno Góis 2023-12-05 12:39:30 +00:00 committed by GitHub
parent fa9d38fc22
commit f348acb3b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 146 additions and 44 deletions

View File

@ -1,4 +1,4 @@
import { styled } from '@mui/material'; import { Alert, styled } from '@mui/material';
import Input from 'component/common/Input/Input'; import Input from 'component/common/Input/Input';
import { PermissionAccordion } from './PermissionAccordion/PermissionAccordion'; import { PermissionAccordion } from './PermissionAccordion/PermissionAccordion';
import { import {
@ -23,6 +23,7 @@ import {
PROJECT_ROLE_TYPES, PROJECT_ROLE_TYPES,
ROOT_ROLE_TYPE, ROOT_ROLE_TYPE,
} from '@server/util/constants'; } from '@server/util/constants';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const StyledInputDescription = styled('p')(({ theme }) => ({ const StyledInputDescription = styled('p')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -38,28 +39,38 @@ const StyledInput = styled(Input)(({ theme }) => ({
maxWidth: theme.spacing(50), maxWidth: theme.spacing(50),
})); }));
const StyledInputFullWidth = styled(Input)({
width: '100%',
});
interface IRoleFormProps { interface IRoleFormProps {
type?: PredefinedRoleType; type?: PredefinedRoleType;
name: string; name: string;
onSetName: (name: string) => void; setName: React.Dispatch<React.SetStateAction<string>>;
validateName: (name: string) => boolean;
description: string; description: string;
setDescription: React.Dispatch<React.SetStateAction<string>>; setDescription: React.Dispatch<React.SetStateAction<string>>;
validateDescription: (description: string) => boolean;
checkedPermissions: ICheckedPermissions; checkedPermissions: ICheckedPermissions;
setCheckedPermissions: React.Dispatch< setCheckedPermissions: React.Dispatch<
React.SetStateAction<ICheckedPermissions> React.SetStateAction<ICheckedPermissions>
>; >;
errors: IRoleFormErrors; errors: IRoleFormErrors;
showErrors: boolean;
} }
export const RoleForm = ({ export const RoleForm = ({
type = ROOT_ROLE_TYPE, type = ROOT_ROLE_TYPE,
name, name,
onSetName, setName,
description, description,
setDescription, setDescription,
checkedPermissions, checkedPermissions,
setCheckedPermissions, setCheckedPermissions,
errors, errors,
showErrors,
validateName,
validateDescription,
}: IRoleFormProps) => { }: IRoleFormProps) => {
const { permissions } = usePermissions({ const { permissions } = usePermissions({
revalidateIfStale: false, revalidateIfStale: false,
@ -95,6 +106,10 @@ export const RoleForm = ({
setCheckedPermissions(newCheckedPermissions); setCheckedPermissions(newCheckedPermissions);
}; };
const handleOnBlur = (callback: Function) => {
setTimeout(() => callback(), 300);
};
return ( return (
<div> <div>
<StyledInputDescription> <StyledInputDescription>
@ -102,27 +117,34 @@ export const RoleForm = ({
</StyledInputDescription> </StyledInputDescription>
<StyledInput <StyledInput
autoFocus autoFocus
label='Role name' label='Role name *'
error={Boolean(errors.name)} error={Boolean(errors.name)}
errorText={errors.name} errorText={errors.name}
value={name} value={name}
onChange={(e) => onSetName(e.target.value)} onChange={(e) => setName(e.target.value)}
onBlur={(e) => handleOnBlur(() => validateName(e.target.value))}
autoComplete='off' autoComplete='off'
required
/> />
<StyledInputDescription> <StyledInputDescription>
What is your new role description? What is your new role description?
</StyledInputDescription> </StyledInputDescription>
<StyledInput <StyledInputFullWidth
label='Role description' label='Role description *'
error={Boolean(errors.description)}
errorText={errors.description}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
onBlur={(e) =>
handleOnBlur(() => validateDescription(e.target.value))
}
autoComplete='off' autoComplete='off'
required
/> />
<StyledInputDescription> <StyledInputDescription>
What is your role allowed to do? What is your role allowed to do?
</StyledInputDescription> </StyledInputDescription>
<Alert severity='info'>
You must select at least one permission.
</Alert>
{categories.map(({ label, type, permissions }) => ( {categories.map(({ label, type, permissions }) => (
<PermissionAccordion <PermissionAccordion
key={label} key={label}
@ -145,6 +167,20 @@ export const RoleForm = ({
onCheckAll={() => onCheckAll(permissions)} onCheckAll={() => onCheckAll(permissions)}
/> />
))} ))}
<ConditionallyRender
condition={showErrors}
show={() => (
<Alert severity='error' icon={false}>
<ul>
{Object.values(errors)
.filter(Boolean)
.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
</Alert>
)}
/>
</div> </div>
); );
}; };

View File

@ -7,11 +7,17 @@ import { ROOT_ROLE_TYPE } from '@server/util/constants';
enum ErrorField { enum ErrorField {
NAME = 'name', NAME = 'name',
DESCRIPTION = 'description',
PERMISSIONS = 'permissions',
} }
export interface IRoleFormErrors { const DEFAULT_ERRORS = {
[ErrorField.NAME]?: string; [ErrorField.NAME]: undefined,
} [ErrorField.DESCRIPTION]: undefined,
[ErrorField.PERMISSIONS]: undefined,
};
export type IRoleFormErrors = Record<ErrorField, string | undefined>;
export const useRoleForm = ( export const useRoleForm = (
initialName = '', initialName = '',
@ -24,7 +30,8 @@ export const useRoleForm = (
const [description, setDescription] = useState(initialDescription); const [description, setDescription] = useState(initialDescription);
const [checkedPermissions, setCheckedPermissions] = const [checkedPermissions, setCheckedPermissions] =
useState<ICheckedPermissions>({}); useState<ICheckedPermissions>({});
const [errors, setErrors] = useState<IRoleFormErrors>({}); const [errors, setErrors] = useState<IRoleFormErrors>(DEFAULT_ERRORS);
const [validated, setValidated] = useState(false);
useEffect(() => { useEffect(() => {
setName(initialName); setName(initialName);
@ -40,6 +47,28 @@ export const useRoleForm = (
setCheckedPermissions(newCheckedPermissions); setCheckedPermissions(newCheckedPermissions);
}, [initialPermissions.length]); }, [initialPermissions.length]);
useEffect(() => {
if (name !== '') {
validateName(name);
} else {
clearError(ErrorField.NAME);
}
}, [name]);
useEffect(() => {
if (description !== '') {
validateDescription(description);
} else {
clearError(ErrorField.DESCRIPTION);
}
}, [description]);
useEffect(() => {
if (validated) {
validatePermissions(checkedPermissions);
}
}, [checkedPermissions]);
const getRolePayload = (type: PredefinedRoleType = ROOT_ROLE_TYPE) => ({ const getRolePayload = (type: PredefinedRoleType = ROOT_ROLE_TYPE) => ({
name, name,
description, description,
@ -70,29 +99,79 @@ export const useRoleForm = (
setErrors((errors) => ({ ...errors, [field]: error })); setErrors((errors) => ({ ...errors, [field]: error }));
}; };
const validateName = (name: string) => {
if (!isNotEmpty(name)) {
setError(ErrorField.NAME, 'Name is required.');
return false;
}
if (!isNameUnique(name)) {
setError(ErrorField.NAME, 'Name must be unique.');
return false;
}
clearError(ErrorField.NAME);
return true;
};
const validateDescription = (description: string) => {
if (!isNotEmpty(description)) {
setError(ErrorField.DESCRIPTION, 'Description is required.');
return false;
}
clearError(ErrorField.DESCRIPTION);
return true;
};
const validatePermissions = (permissions: ICheckedPermissions) => {
if (!hasPermissions(permissions)) {
setError(
ErrorField.PERMISSIONS,
'You must select at least one permission.',
);
return false;
}
clearError(ErrorField.PERMISSIONS);
return true;
};
const validate = () => {
const validName = validateName(name);
const validDescription = validateDescription(description);
const validPermissions = validatePermissions(checkedPermissions);
setValidated(true);
return validName && validDescription && validPermissions;
};
const showErrors = validated && Object.values(errors).some(Boolean);
const reload = () => { const reload = () => {
setName(initialName); setName(initialName);
setDescription(initialDescription); setDescription(initialDescription);
setCheckedPermissions( setCheckedPermissions(
permissionsToCheckedPermissions(initialPermissions), permissionsToCheckedPermissions(initialPermissions),
); );
setValidated(false);
setErrors(DEFAULT_ERRORS);
}; };
return { return {
name, name,
description,
checkedPermissions,
errors,
setName, setName,
validateName,
description,
setDescription, setDescription,
validateDescription,
checkedPermissions,
setCheckedPermissions, setCheckedPermissions,
getRolePayload, getRolePayload,
clearError, errors,
setError, showErrors,
isNameUnique, validate,
isNotEmpty,
hasPermissions,
ErrorField,
reload, reload,
}; };
}; };

View File

@ -48,18 +48,16 @@ export const RoleModal = ({
const { const {
name, name,
setName, setName,
validateName,
description, description,
setDescription, setDescription,
validateDescription,
checkedPermissions, checkedPermissions,
setCheckedPermissions, setCheckedPermissions,
getRolePayload, getRolePayload,
isNameUnique,
isNotEmpty,
hasPermissions,
errors, errors,
setError, showErrors,
clearError, validate,
ErrorField,
reload: reloadForm, reload: reloadForm,
} = useRoleForm(role?.name, role?.description, role?.permissions); } = useRoleForm(role?.name, role?.description, role?.permissions);
const { refetch: refetchRoles } = useRoles(); const { refetch: refetchRoles } = useRoles();
@ -68,11 +66,6 @@ export const RoleModal = ({
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const editing = role !== undefined; const editing = role !== undefined;
const isValid =
isNameUnique(name) &&
isNotEmpty(name) &&
isNotEmpty(description) &&
hasPermissions(checkedPermissions);
const payload = getRolePayload(type); const payload = getRolePayload(type);
@ -85,14 +78,6 @@ export const RoleModal = ({
--data-raw '${JSON.stringify(payload, undefined, 2)}'`; --data-raw '${JSON.stringify(payload, undefined, 2)}'`;
}; };
const onSetName = (name: string) => {
clearError(ErrorField.NAME);
if (!isNameUnique(name)) {
setError(ErrorField.NAME, 'A role with that name already exists.');
}
setName(name);
};
const refetch = () => { const refetch = () => {
refetchRoles(); refetchRoles();
refetchRole(); refetchRole();
@ -101,7 +86,7 @@ export const RoleModal = ({
const onSubmit = async (e: FormEvent<HTMLFormElement>) => { const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (!isValid) return; if (!validate()) return;
try { try {
if (editing) { if (editing) {
@ -151,19 +136,21 @@ export const RoleModal = ({
<RoleForm <RoleForm
type={type} type={type}
name={name} name={name}
onSetName={onSetName} setName={setName}
validateName={validateName}
description={description} description={description}
setDescription={setDescription} setDescription={setDescription}
validateDescription={validateDescription}
checkedPermissions={checkedPermissions} checkedPermissions={checkedPermissions}
setCheckedPermissions={setCheckedPermissions} setCheckedPermissions={setCheckedPermissions}
errors={errors} errors={errors}
showErrors={showErrors}
/> />
<StyledButtonContainer> <StyledButtonContainer>
<Button <Button
type='submit' type='submit'
variant='contained' variant='contained'
color='primary' color='primary'
disabled={!isValid}
> >
{editing ? 'Save' : 'Add'} role {editing ? 'Save' : 'Add'} role
</Button> </Button>