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:
parent
fa9d38fc22
commit
f348acb3b9
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user