0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-03 23:00:14 -05:00

AdminX user settings related cleanup (#17520)

refs. https://github.com/TryGhost/Product/issues/3349

- added validation for user name
- added onBlur validation for fields
- changed textfield label color on focus
This commit is contained in:
Peter Zimon 2023-07-27 18:29:02 +02:00 committed by GitHub
parent e45b947d61
commit 0a0fcbc551
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 73 additions and 34 deletions

View file

@ -46,7 +46,7 @@ function App({ghostVersion, officialThemes}: AppProps) {
</div>
<div className="relative flex-auto pt-[3vmin] md:ml-[300px] md:pt-[85px]">
<div className='fixed inset-x-0 top-0 z-[5] h-[130px] bg-gradient-to-t from-transparent to-white to-60%'></div>
<div className='pointer-events-none fixed inset-x-0 top-0 z-[5] h-[130px] bg-gradient-to-t from-transparent to-white to-60%'></div>
<Settings />
</div>
</div>

View file

@ -29,7 +29,6 @@ const TextField: React.FC<TextFieldProps> = ({
type = 'text',
inputRef,
title,
//titleColor = 'grey',
hideTitle,
value,
error,
@ -50,12 +49,12 @@ const TextField: React.FC<TextFieldProps> = ({
const id = useId();
const textFieldClasses = !unstyled && clsx(
'h-10 w-full border-b py-2',
'peer order-2 h-10 w-full border-b py-2',
clearBg ? 'bg-transparent' : 'bg-grey-75 px-[10px]',
error ? `border-red` : `${disabled ? 'border-grey-300' : 'border-grey-500 hover:border-grey-700 focus:border-black'}`,
(title && !hideTitle && !clearBg) && `mt-2`,
(disabled ? 'text-grey-700' : ''),
rightPlaceholder && 'peer w-0 grow',
rightPlaceholder && 'w-0 grow',
className
);
@ -91,17 +90,11 @@ const TextField: React.FC<TextFieldProps> = ({
}
if (title || hint) {
// let titleGrey = false;
// if (titleColor === 'auto') {
// titleGrey = value ? true : false;
// } else {
// titleGrey = titleColor === 'grey' ? true : false;
// }
return (
<div className={`flex flex-col ${containerClassName}`}>
{title && <Heading className={hideTitle ? 'sr-only' : ''} grey={true} htmlFor={id} useLabelTag={true}>{title}</Heading>}
{field}
{hint && <Hint className={hintClassName} color={error ? 'red' : ''}>{hint}</Hint>}
{title && <Heading className={hideTitle ? 'sr-only' : 'order-1 !text-grey-700 peer-focus:!text-black'} htmlFor={id} useLabelTag={true}>{title}</Heading>}
{hint && <Hint className={'order-3' + hintClassName} color={error ? 'red' : ''}>{hint}</Hint>}
</div>
);
} else {

View file

@ -19,6 +19,7 @@ import {FileService, ServicesContext} from '../../providers/ServiceProvider';
import {User} from '../../../types/api';
import {isAdminUser, isOwnerUser} from '../../../utils/helpers';
import {showToast} from '../../../admin-x-ds/global/Toast';
import { toast } from 'react-hot-toast';
interface CustomHeadingProps {
children?: React.ReactNode;
@ -28,9 +29,15 @@ interface UserDetailProps {
user: User;
setUserData?: (user: User) => void;
errors?: {
name?: string;
url?: string;
email?: string;
};
validators?: {
name: (name: string) => boolean,
email: (email: string) => boolean,
url: (url: string) => boolean
}
}
const CustomHeader: React.FC<CustomHeadingProps> = ({children}) => {
@ -89,13 +96,18 @@ const RoleSelector: React.FC<UserDetailProps> = ({user, setUserData}) => {
/>
);
};
const BasicInputs: React.FC<UserDetailProps> = ({errors, user, setUserData}) => {
const BasicInputs: React.FC<UserDetailProps> = ({errors, validators, user, setUserData}) => {
return (
<SettingGroupContent>
<TextField
hint="Use real name so people can recognize you"
error={!!errors?.name}
hint={errors?.name || "Use real name so people can recognize you"}
title="Full name"
value={user.name}
onBlur={(e) => {
validators?.name(e.target.value);
}}
onChange={(e) => {
setUserData?.({...user, name: e.target.value});
}}
@ -105,6 +117,9 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, user, setUserData}) =>
hint={errors?.email || ''}
title="Email"
value={user.email}
onBlur={(e) => {
validators?.email(e.target.value);
}}
onChange={(e) => {
setUserData?.({...user, email: e.target.value});
}}
@ -114,19 +129,19 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, user, setUserData}) =>
);
};
const Basic: React.FC<UserDetailProps> = ({errors, user, setUserData}) => {
const Basic: React.FC<UserDetailProps> = ({errors, validators, user, setUserData}) => {
return (
<SettingGroup
border={false}
customHeader={<CustomHeader>Basic info</CustomHeader>}
title='Basic'
>
<BasicInputs errors={errors} setUserData={setUserData} user={user} />
<BasicInputs errors={errors} setUserData={setUserData} user={user} validators={validators} />
</SettingGroup>
);
};
const DetailsInputs: React.FC<UserDetailProps> = ({errors, user, setUserData}) => {
const DetailsInputs: React.FC<UserDetailProps> = ({errors, validators, user, setUserData}) => {
return (
<SettingGroupContent>
<TextField
@ -149,6 +164,9 @@ const DetailsInputs: React.FC<UserDetailProps> = ({errors, user, setUserData}) =
hint={errors?.url || ''}
title="Website"
value={user.website}
onBlur={(e) => {
validators?.url(e.target.value);
}}
onChange={(e) => {
setUserData?.({...user, website: e.target.value});
}}
@ -179,14 +197,14 @@ const DetailsInputs: React.FC<UserDetailProps> = ({errors, user, setUserData}) =
);
};
const Details: React.FC<UserDetailProps> = ({errors, user, setUserData}) => {
const Details: React.FC<UserDetailProps> = ({errors, validators, user, setUserData}) => {
return (
<SettingGroup
border={false}
customHeader={<CustomHeader>Details</CustomHeader>}
title='Details'
>
<DetailsInputs errors={errors} setUserData={setUserData} user={user} />
<DetailsInputs errors={errors} setUserData={setUserData} user={user} validators={validators} />
</SettingGroup>
);
};
@ -406,6 +424,7 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
const [userData, setUserData] = useState(user);
const [saveState, setSaveState] = useState('');
const [errors, setErrors] = useState<{
name?: string;
email?: string;
url?: string;
}>({});
@ -569,6 +588,7 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
]);
let okLabel = saveState === 'saved' ? 'Saved' : 'Save & close';
if (saveState === 'saving') {
okLabel = 'Saving...';
} else if (saveState === 'saved') {
@ -582,6 +602,29 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
const suspendedText = userData.status === 'inactive' ? ' (Suspended)' : '';
const validators = {
name: (name: string) => {
setErrors?.((_errors) => {
return {..._errors, name: name ? '' : 'Please enter a name'};
});
return !!name;
},
email: (email: string) => {
const valid = validator.isEmail(email);
setErrors?.((_errors) => {
return {..._errors, email: valid ? '' : 'Please enter a valid email address'};
});
return valid;
},
url: (url: string) => {
const valid = !url || validator.isURL(url);
setErrors?.((_errors) => {
return {..._errors, url: valid ? '' : 'Please enter a valid URL'};
});
return valid;
}
};
return (
<Modal
okLabel={okLabel}
@ -590,21 +633,22 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
testId='user-detail-modal'
onOk={async () => {
setSaveState('saving');
if (!validator.isEmail(userData.email)) {
setErrors?.((_errors) => {
return {..._errors, email: 'Please enter a valid email address'};
});
setSaveState('');
return;
}
if (!validator.isURL(userData.url)) {
setErrors?.((_errors) => {
return {..._errors, url: 'Please enter a valid URL'};
let error = false;
if (!validators.name(userData.name) || !validators.email(userData.email) || !validators.url(userData.website)) {
error = true;
}
if (error) {
showToast({
type: 'pageError',
message: "Can't save user! One or more fields have errors, please doublecheck you filled all mandatory fields"
});
setSaveState('');
return;
}
toast.dismiss();
await updateUser?.(userData);
setSaveState('saved');
}}
@ -658,8 +702,8 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
</div>
</div>
<div className='mt-10 grid grid-cols-2 gap-x-12 gap-y-20'>
<Basic errors={errors} setUserData={setUserData} user={userData} />
<Details errors={errors} setUserData={setUserData} user={userData} />
<Basic errors={errors} setUserData={setUserData} user={userData} validators={validators} />
<Details errors={errors} setUserData={setUserData} user={userData} validators={validators} />
<EmailNotifications setUserData={setUserData} user={userData} />
<Password user={userData} />
</div>

View file

@ -18,6 +18,7 @@ import {Tier} from '../../../../types/api';
import {currencies, currencyFromDecimal, currencyGroups, currencyToDecimal, getSymbol} from '../../../../utils/currency';
import {getSettingValues} from '../../../../utils/helpers';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import { toast } from 'react-hot-toast';
import {useTiers} from '../../../providers/ServiceProvider';
interface TierDetailModalProps {
@ -121,7 +122,7 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
if (Object.values(validators).filter(validator => validator()).length) {
showToast({
type: 'pageError',
message: 'One or more fields have errors'
message: "Can't save tier! One or more fields have errors, please doublecheck you filled all mandatory fields"
});
return;
}
@ -129,6 +130,7 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
handleSave();
if (saveState !== 'unsaved') {
toast.dismiss();
modal.remove();
}
}}

View file

@ -32,7 +32,7 @@ test.describe('User profile', async () => {
await modal.getByLabel('Email').fill('newadmin@test.com');
await modal.getByLabel('Slug').fill('newadmin');
await modal.getByLabel('Location').fill('some location');
await modal.getByLabel('Website').fill('some site');
await modal.getByLabel('Website').fill('https://example.com');
await modal.getByLabel('Facebook profile').fill('some fb');
await modal.getByLabel('Twitter profile').fill('some tw');
await modal.getByLabel('Bio').fill('some bio');
@ -53,7 +53,7 @@ test.describe('User profile', async () => {
name: 'New Admin',
slug: 'newadmin',
location: 'some location',
website: 'some site',
website: 'https://example.com',
facebook: 'some fb',
twitter: 'some tw',
bio: 'some bio',