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

Improved modal save handling in Admin X (#18794)

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

---

<!-- Leave the line below if you'd like GitHub Copilot to generate a
summary from your commit -->
<!--
copilot:summary
-->
### <samp>🤖 Generated by Copilot at 55149bc</samp>

Refactored various settings forms and modals in the admin app to use the
updated `useForm` hook, which simplifies the form state management,
validation, and saving logic. Improved the UI and UX of the modal ok
buttons by using the `okProps` object and the `saveState` from the hook.
Added options to save the forms without changes and to fake the saving
when unchanged. Fixed some bugs and removed unused code.
This commit is contained in:
Jono M 2023-10-30 17:57:19 +00:00 committed by GitHub
parent 766374a44a
commit 48b90e4253
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 282 additions and 263 deletions

View file

@ -27,11 +27,17 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
const {mutateAsync: uploadImage} = useUploadImage();
const handleError = useHandleError();
const {formState, updateForm, handleSave, saveState, errors, clearError, validate} = useForm({
const {formState, updateForm, handleSave, saveState, errors, clearError, validate, okProps} = useForm({
initialState: integration,
savingDelay: 500,
savedDelay: 500,
onSave: async () => {
await editIntegration(formState);
},
onSavedStateReset: () => {
modal.remove();
updateRoute('integrations');
},
onSaveError: handleError,
onValidate: () => {
const newErrors: Record<string, string> = {};
@ -82,19 +88,17 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
afterClose={() => {
updateRoute('integrations');
}}
buttonsDisabled={okProps.disabled}
dirty={saveState === 'unsaved'}
okColor='black'
okLabel='Save & close'
okColor={okProps.color}
okLabel={okProps.label || 'Save & close'}
size='md'
testId='custom-integration-modal'
title={formState.name}
stickyFooter
onOk={async () => {
toast.remove();
if (await handleSave()) {
modal.remove();
updateRoute('integrations');
} else {
if (!(await handleSave({fakeWhenUnchanged: true}))) {
showToast({
type: 'pageError',
message: 'Can\'t save integration, please double check that you\'ve filled all mandatory fields.'

View file

@ -438,8 +438,9 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
const {updateRoute} = useRouting();
const handleError = useHandleError();
const {formState, saveState, updateForm, setFormState, handleSave, validate, errors, clearError} = useForm({
const {formState, saveState, updateForm, setFormState, handleSave, validate, errors, clearError, okProps} = useForm({
initialState: newsletter,
savingDelay: 500,
onSave: async () => {
const {newsletters, meta} = await editNewsletter(formState);
if (meta?.sent_email_verification) {
@ -489,12 +490,12 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
return <PreviewModalContent
afterClose={() => updateRoute('newsletters')}
buttonsDisabled={saveState === 'saving'}
buttonsDisabled={okProps.disabled}
cancelLabel='Close'
deviceSelector={false}
dirty={saveState === 'unsaved'}
okColor={saveState === 'saved' ? 'green' : 'black'}
okLabel={saveState === 'saved' ? 'Saved' : (saveState === 'saving' ? 'Saving...' : 'Save')}
okColor={okProps.color}
okLabel={okProps.label || 'Save'}
preview={preview}
previewBgColor={'grey'}
previewToolbar={false}
@ -503,7 +504,7 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
testId='newsletter-modal'
title='Newsletter'
onOk={async () => {
if (!(await handleSave())) {
if (!(await handleSave({fakeWhenUnchanged: true}))) {
showToast({
type: 'pageError',
message: 'Can\'t save newsletter, please double check that you\'ve filled all mandatory fields.'

View file

@ -10,9 +10,10 @@ import Modal from '../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import ProfileBasics from './users/ProfileBasics';
import ProfileDetails from './users/ProfileDetails';
import React, {useCallback, useEffect, useState} from 'react';
import React, {useCallback, useEffect} from 'react';
import StaffToken from './users/StaffToken';
import clsx from 'clsx';
import useForm, {ErrorMessages} from '../../../hooks/useForm';
import useHandleError from '../../../utils/api/handleError';
import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useRouting from '../../../hooks/useRouting';
@ -28,11 +29,69 @@ import {toast} from 'react-hot-toast';
import {useGlobalData} from '../../providers/GlobalDataProvider';
import {validateFacebookUrl, validateTwitterUrl} from '../../../utils/socialUrls';
const validators: Record<string, (u: Partial<User>) => string> = {
name: ({name}) => {
let error = '';
if (!name) {
error = 'Please enter a name';
}
if (name && name.length > 191) {
error = 'Name is too long';
}
return error;
},
email: ({email}) => {
const valid = validator.isEmail(email || '');
return valid ? '' : 'Please enter a valid email address';
},
url: ({url}) => {
const valid = !url || validator.isURL(url);
return valid ? '' : 'Please enter a valid URL';
},
bio: ({bio}) => {
const valid = !bio || bio.length <= 200;
return valid ? '' : 'Bio is too long';
},
location: ({location}) => {
const valid = !location || location.length <= 150;
return valid ? '' : 'Location is too long';
},
website: ({website}) => {
const valid = !website || (validator.isURL(website) && website.length <= 2000);
return valid ? '' : 'Website is not a valid url';
},
facebook: ({facebook}) => {
try {
validateFacebookUrl(facebook || '');
return '';
} catch (e) {
if (e instanceof Error) {
return e.message;
}
return '';
}
},
twitter: ({twitter}) => {
try {
validateTwitterUrl(twitter || '');
return '';
} catch (e) {
if (e instanceof Error) {
return e.message;
}
return '';
}
}
};
export interface UserDetailProps {
user: User;
setUserData: (user: User) => void;
errors: {[key in keyof User]?: string};
validators: Record<string, (user: Partial<User>) => boolean>;
validateField: <K extends keyof User>(key: K, value: User[K]) => boolean;
clearError: (key: keyof User) => void;
}
@ -47,15 +106,39 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
const {updateRoute} = useRouting();
const {ownerUser} = useStaffUsers();
const {currentUser} = useGlobalData();
const [userData, _setUserData] = useState(user);
const [saveState, setSaveState] = useState<'' | 'unsaved' | 'saving' | 'saved'>('');
const [errors, setErrors] = useState<UserDetailProps['errors']>({});
const clearError = (key: keyof User) => setErrors(errs => ({...errs, [key]: undefined}));
const setUserData = (newUserData: User | ((current: User) => User)) => {
_setUserData(newUserData);
setSaveState('unsaved');
const handleError = useHandleError();
const {formState, setFormState, saveState, handleSave, updateForm, errors, setErrors, clearError, okProps} = useForm({
initialState: user,
savingDelay: 500,
savedDelay: 500,
onValidate: (values) => {
return Object.entries(validators).reduce<ErrorMessages>((newErrors, [key, validate]) => {
const error = validate(values);
if (error) {
newErrors[key] = error;
}
return newErrors;
}, {});
},
onSave: async (values) => {
await updateUser?.(values);
},
onSavedStateReset: () => {
mainModal.remove();
navigateOnClose();
},
onSaveError: handleError
});
const setUserData = (newData: User) => updateForm(() => newData);
const validateField = <K extends keyof User>(key: K, value: User[K]) => {
const error = validators[key]?.({[key]: value});
if (error) {
setErrors({...errors, [key]: error});
return false;
} else {
clearError(key);
return true;
}
};
const mainModal = useModal();
@ -64,7 +147,6 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
const {mutateAsync: deleteUser} = useDeleteUser();
const {mutateAsync: makeOwner} = useMakeOwner();
const limiter = useLimiter();
const handleError = useHandleError();
// Pintura integration
const {settings} = useGlobalData();
@ -86,15 +168,6 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
}
}, [currentUser, updateRoute]);
useEffect(() => {
if (saveState === 'saved') {
setTimeout(() => {
mainModal.remove();
navigateOnClose();
}, 300);
}
}, [mainModal, navigateOnClose, saveState, updateRoute]);
const confirmSuspend = async (_user: User) => {
if (_user.status === 'inactive' && _user.roles[0].name !== 'Contributor') {
try {
@ -133,7 +206,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
};
try {
await updateUser(updatedUserData);
setUserData(updatedUserData);
setFormState(() => updatedUserData);
modal?.remove();
showToast({
message: _user.status === 'inactive' ? 'User un-suspended' : 'User suspended',
@ -201,12 +274,12 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
switch (image) {
case 'cover_image':
setUserData?.((_user) => {
updateForm((_user) => {
return {..._user, cover_image: imageUrl};
});
break;
case 'profile_image':
setUserData?.((_user) => {
updateForm((_user) => {
return {..._user, profile_image: imageUrl};
});
break;
@ -219,12 +292,12 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
const handleImageDelete = (image: string) => {
switch (image) {
case 'cover_image':
setUserData?.((_user) => {
updateForm((_user) => {
return {..._user, cover_image: ''};
});
break;
case 'profile_image':
setUserData?.((_user) => {
updateForm((_user) => {
return {..._user, profile_image: ''};
});
break;
@ -234,7 +307,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
const showMenu = hasAdminAccess(currentUser) || (isEditorUser(currentUser) && isAuthorOrContributor(user));
let menuItems: MenuItem[] = [];
if (isOwnerUser(currentUser) && isAdminUser(userData) && userData.status !== 'inactive') {
if (isOwnerUser(currentUser) && isAdminUser(formState) && formState.status !== 'inactive') {
menuItems.push({
id: 'make-owner',
label: 'Make owner',
@ -242,11 +315,11 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
});
}
if (userData.id !== currentUser.id && (
if (formState.id !== currentUser.id && (
(hasAdminAccess(currentUser) && !isOwnerUser(user)) ||
(isEditorUser(currentUser) && isAuthorOrContributor(user))
)) {
let suspendUserLabel = userData.status === 'inactive' ? 'Un-suspend user' : 'Suspend user';
let suspendUserLabel = formState.status === 'inactive' ? 'Un-suspend user' : 'Suspend user';
menuItems.push({
id: 'delete-user',
@ -258,7 +331,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
id: 'suspend-user',
label: suspendUserLabel,
onClick: () => {
confirmSuspend(userData);
confirmSuspend(formState);
}
});
}
@ -268,18 +341,10 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
label: 'View user activity',
onClick: () => {
mainModal.remove();
updateRoute(`history/view/${userData.id}`);
updateRoute(`history/view/${formState.id}`);
}
});
let okLabel = saveState === 'saved' ? 'Saved' : 'Save & close';
if (saveState === 'saving') {
okLabel = 'Saving...';
} else if (saveState === 'saved') {
okLabel = 'Saved';
}
const coverEditButtonBaseClasses = 'bg-[rgba(0,0,0,0.75)] rounded text-sm text-white flex items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 transition-all cursor-pointer font-medium nowrap';
const fileUploadButtonClasses = clsx(
@ -294,122 +359,35 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
coverEditButtonBaseClasses
);
const suspendedText = userData.status === 'inactive' ? ' (Suspended)' : '';
const validators: Record<string, (u: Partial<User>) => boolean> = {
name: ({name}) => {
let error = '';
if (!name) {
error = 'Please enter a name';
}
if (name && name.length > 191) {
error = 'Name is too long';
}
setErrors?.((_errors) => {
return {..._errors, name: error};
});
return !error;
},
email: ({email}) => {
const valid = validator.isEmail(email || '');
setErrors?.((_errors) => {
return {..._errors, email: valid ? '' : 'Please enter a valid email address'};
});
return valid;
},
url: ({url}) => {
const valid = !url || validator.isURL(url);
setErrors?.((_errors) => {
return {..._errors, url: valid ? '' : 'Please enter a valid URL'};
});
return valid;
},
bio: ({bio}) => {
const valid = !bio || bio.length <= 200;
setErrors?.((_errors) => {
return {..._errors, bio: valid ? '' : 'Bio is too long'};
});
return valid;
},
location: ({location}) => {
const valid = !location || location.length <= 150;
setErrors?.((_errors) => {
return {..._errors, location: valid ? '' : 'Location is too long'};
});
return valid;
},
website: ({website}) => {
const valid = !website || (validator.isURL(website) && website.length <= 2000);
setErrors?.((_errors) => {
return {..._errors, website: valid ? '' : 'Website is not a valid url'};
});
return valid;
},
facebook: ({facebook}) => {
try {
validateFacebookUrl(facebook || '');
return true;
} catch (e) {
if (e instanceof Error) {
const message = e.message;
setErrors?.(_errors => ({..._errors, facebook: message}));
}
return false;
}
},
twitter: ({twitter}) => {
try {
validateTwitterUrl(twitter || '');
return true;
} catch (e) {
if (e instanceof Error) {
const message = e.message;
setErrors?.(_errors => ({..._errors, twitter: message}));
}
return false;
}
}
};
const suspendedText = formState.status === 'inactive' ? ' (Suspended)' : '';
return (
<Modal
afterClose={navigateOnClose}
animate={canAccessSettings(currentUser)}
backDrop={canAccessSettings(currentUser)}
buttonsDisabled={okProps.disabled}
dirty={saveState === 'unsaved'}
okLabel={okLabel}
okColor={okProps.color}
okLabel={okProps.label || 'Save & close'}
size={canAccessSettings(currentUser) ? 'lg' : 'bleed'}
stickyFooter={true}
testId='user-detail-modal'
onOk={async () => {
setSaveState('saving');
let isValid = true;
if (Object.values(validators).map(validate => validate(userData)).includes(false)) {
isValid = false;
}
toast.remove();
if (!isValid) {
if (!(await handleSave({fakeWhenUnchanged: true}))) {
showToast({
type: 'pageError',
message: 'Can\'t save user, please double check that you\'ve filled all mandatory fields.'
});
setSaveState('');
return;
}
await updateUser?.(userData);
setSaveState('saved');
}}
>
<div>
<div className={`relative ${canAccessSettings(currentUser) ? '-mx-8 -mt-8 rounded-t' : '-mx-10 -mt-10'} bg-gradient-to-tr from-grey-900 to-black`}>
<div className='flex min-h-[40vmin] flex-wrap items-end justify-between bg-cover bg-center' style={{
backgroundImage: `url(${userData.cover_image})`
backgroundImage: `url(${formState.cover_image})`
}}>
<div className='flex w-full max-w-[620px] flex-col gap-5 p-8 md:max-w-[auto] md:flex-row md:items-center'>
<div>
@ -422,12 +400,12 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
id='avatar'
imageClassName='w-full h-full object-cover rounded-full shrink-0'
imageContainerClassName='relative group bg-cover bg-center -ml-2 h-[80px] w-[80px] shrink-0'
imageURL={userData.profile_image}
imageURL={formState.profile_image}
pintura={
{
isEnabled: editor.isEnabled,
openEditor: async () => editor.openEditor({
image: userData.profile_image || '',
image: formState.profile_image || '',
handleSave: async (file:File) => {
handleImageUpload('profile_image', file);
}
@ -460,12 +438,12 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
fileUploadClassName={fileUploadButtonClasses}
id='cover-image'
imageClassName='hidden'
imageURL={userData.cover_image || ''}
imageURL={formState.cover_image || ''}
pintura={
{
isEnabled: editor.isEnabled,
openEditor: async () => editor.openEditor({
image: userData.cover_image || '',
image: formState.cover_image || '',
handleSave: async (file:File) => {
handleImageUpload('cover_image', file);
}
@ -487,13 +465,13 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
</div>
</div>
<div className={`${!canAccessSettings(currentUser) && 'mx-auto max-w-4xl'} mt-10 grid grid-cols-1 gap-x-12 gap-y-20 md:grid-cols-2`}>
<ProfileBasics clearError={clearError} errors={errors} setUserData={setUserData} user={userData} validators={validators} />
<ProfileBasics clearError={clearError} errors={errors} setUserData={setUserData} user={formState} validateField={validateField} />
<div className='flex flex-col justify-between gap-10'>
<ProfileDetails clearError={clearError} errors={errors} setUserData={setUserData} user={userData} validators={validators} />
<ProfileDetails clearError={clearError} errors={errors} setUserData={setUserData} user={formState} validateField={validateField} />
{user.id === currentUser.id && <StaffToken />}
</div>
<EmailNotifications setUserData={setUserData} user={userData} />
<ChangePasswordForm user={userData} />
<EmailNotifications setUserData={setUserData} user={formState} />
<ChangePasswordForm user={formState} />
</div>
</div>
</Modal>

View file

@ -7,7 +7,7 @@ import {UserDetailProps} from '../UserDetailModal';
import {hasAdminAccess} from '../../../../api/users';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
const BasicInputs: React.FC<UserDetailProps> = ({errors, validators, clearError, user, setUserData}) => {
const BasicInputs: React.FC<UserDetailProps> = ({errors, validateField, clearError, user, setUserData}) => {
const {currentUser} = useGlobalData();
return (
@ -18,7 +18,7 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, validators, clearError,
title="Full name"
value={user.name}
onBlur={(e) => {
validators.name({name: e.target.value});
validateField('name', e.target.value);
}}
onChange={(e) => {
setUserData({...user, name: e.target.value});
@ -31,7 +31,7 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, validators, clearError,
title="Email"
value={user.email}
onBlur={(e) => {
validators.email({email: e.target.value});
validateField('email', e.target.value);
}}
onChange={(e) => {
setUserData({...user, email: e.target.value});

View file

@ -7,7 +7,7 @@ import {UserDetailProps} from '../UserDetailModal';
import {facebookHandleToUrl, facebookUrlToHandle, twitterHandleToUrl, twitterUrlToHandle, validateFacebookUrl, validateTwitterUrl} from '../../../../utils/socialUrls';
import {useState} from 'react';
export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, validators, user, setUserData}) => {
export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, validateField, user, setUserData}) => {
const [facebookUrl, setFacebookUrl] = useState(user.facebook ? facebookHandleToUrl(user.facebook) : '');
const [twitterUrl, setTwitterUrl] = useState(user.twitter ? twitterHandleToUrl(user.twitter) : '');
@ -19,7 +19,7 @@ export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, va
title="Location"
value={user.location || ''}
onBlur={(e) => {
validators.location({location: e.target.value});
validateField('location', e.target.value);
}}
onChange={(e) => {
setUserData({...user, location: e.target.value});
@ -31,7 +31,7 @@ export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, va
title="Website"
value={user.website || ''}
onBlur={(e) => {
validators.url({url: e.target.value});
validateField('url', e.target.value);
}}
onChange={(e) => {
setUserData({...user, website: e.target.value});
@ -43,7 +43,7 @@ export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, va
title="Facebook profile"
value={facebookUrl}
onBlur={(e) => {
if (validators.facebook({facebook: e.target.value})) {
if (validateField('facebook', e.target.value)) {
const url = validateFacebookUrl(e.target.value);
setFacebookUrl(url);
setUserData({...user, facebook: facebookUrlToHandle(url)});
@ -59,7 +59,7 @@ export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, va
title="X (formerly Twitter) profile"
value={twitterUrl}
onBlur={(e) => {
if (validators.twitter({twitter: e.target.value})) {
if (validateField('twitter', e.target.value)) {
const url = validateTwitterUrl(e.target.value);
setTwitterUrl(url);
setUserData({...user, twitter: twitterUrlToHandle(url)});
@ -75,7 +75,7 @@ export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, va
title="Bio"
value={user.bio || ''}
onBlur={(e) => {
validators.bio({bio: e.target.value});
validateField('bio', e.target.value);
}}
onChange={(e) => {
setUserData({...user, bio: e.target.value});

View file

@ -115,12 +115,14 @@ const PortalModal: React.FC = () => {
}
}, [handleError, verifyEmail, verifyToken]);
const {formState, setFormState, saveState, handleSave, updateForm} = useForm({
const {formState, setFormState, saveState, handleSave, updateForm, okProps} = useForm({
initialState: {
settings: settings as Dirtyable<Setting>[],
tiers: allTiers as Dirtyable<Tier>[] || []
},
savingDelay: 500,
onSave: async () => {
await Promise.all(formState.tiers.filter(({dirty}) => dirty).map(tier => editTier(tier)));
setFormState(state => ({...state, tiers: formState.tiers.map(tier => ({...tier, dirty: false}))}));
@ -204,22 +206,17 @@ const PortalModal: React.FC = () => {
{id: 'account', title: 'Account page'},
{id: 'links', title: 'Links'}
];
let okLabel = 'Save';
if (saveState === 'saving') {
okLabel = 'Saving...';
} else if (saveState === 'saved') {
okLabel = 'Saved';
}
return <PreviewModalContent
afterClose={() => {
updateRoute('portal');
}}
buttonsDisabled={okProps.disabled}
cancelLabel='Close'
deviceSelector={false}
dirty={saveState === 'unsaved'}
okColor={saveState === 'saved' ? 'green' : 'black'}
okLabel={okLabel}
okColor={okProps.color}
okLabel={okProps.label || 'Save'}
preview={preview}
previewBgColor={selectedPreviewTab === 'links' ? 'white' : 'greygradient'}
previewToolbarTabs={previewTabs}

View file

@ -22,12 +22,16 @@ const EditRecommendationModal: React.FC<RoutingModalProps & EditRecommendationMo
const {mutateAsync: deleteRecommendation} = useDeleteRecommendation();
const handleError = useHandleError();
const {formState, updateForm, handleSave, saveState, errors, clearError, setErrors} = useForm({
const {formState, updateForm, handleSave, errors, clearError, setErrors, okProps} = useForm({
initialState: {
...recommendation
},
savingDelay: 500,
savedDelay: 500,
onSave: async (state) => {
await editRecommendation(state);
},
onSavedStateReset: () => {
modal.remove();
updateRoute('recommendations');
},
@ -46,14 +50,6 @@ const EditRecommendationModal: React.FC<RoutingModalProps & EditRecommendationMo
}
});
let okLabel = 'Save';
if (saveState === 'saving') {
okLabel = 'Saving...';
} else if (saveState === 'saved') {
okLabel = 'Saved';
}
let leftButtonProps = {
label: 'Delete',
link: true,
@ -94,20 +90,16 @@ const EditRecommendationModal: React.FC<RoutingModalProps & EditRecommendationMo
}}
animate={animate ?? true}
backDropClick={false}
buttonsDisabled={okProps.disabled}
cancelLabel={'Cancel'}
leftButtonProps={leftButtonProps}
okColor='black'
okLabel={okLabel}
okColor={okProps.color}
okLabel={okProps.label || 'Save & close'}
size='sm'
testId='edit-recommendation-modal'
title={'Edit recommendation'}
stickyFooter
onOk={async () => {
if (saveState === 'saving') {
// Already saving
return;
}
dismissAllToasts();
try {
await handleSave({force: true});

View file

@ -6,14 +6,14 @@ import Heading from '../../../../admin-x-ds/global/Heading';
import Icon from '../../../../admin-x-ds/global/Icon';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useEffect, useRef, useState} from 'react';
import React, {useEffect, useRef} from 'react';
import Select from '../../../../admin-x-ds/global/form/Select';
import SortableList from '../../../../admin-x-ds/global/SortableList';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import TierDetailPreview from './TierDetailPreview';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import URLTextField from '../../../../admin-x-ds/global/form/URLTextField';
import useForm from '../../../../hooks/useForm';
import useForm, {ErrorMessages} from '../../../../hooks/useForm';
import useHandleError from '../../../../utils/api/handleError';
import useRouting from '../../../../hooks/useRouting';
import useSettingGroup from '../../../../hooks/useSettingGroup';
@ -41,14 +41,13 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
const {localSettings, siteData} = useSettingGroup();
const siteTitle = getSettingValues(localSettings, ['title']) as string[];
const [errors, setErrors] = useState<{ [key in keyof Tier]?: string }>({}); // eslint-disable-line no-unused-vars
const setError = (field: keyof Tier, error: string | undefined) => {
setErrors(errs => ({...errs, [field]: error}));
return error;
const validators: {[key in keyof Tier]?: () => string | undefined} = {
name: () => (formState.name ? undefined : 'You must specify a name'),
monthly_price: () => (formState.type !== 'free' ? validateCurrencyAmount(formState.monthly_price || 0, formState.currency, {allowZero: false}) : undefined),
yearly_price: () => (formState.type !== 'free' ? validateCurrencyAmount(formState.yearly_price || 0, formState.currency, {allowZero: false}) : undefined)
};
const {formState, saveState, updateForm, handleSave} = useForm<TierFormState>({
const {formState, saveState, updateForm, handleSave, errors, setErrors, clearError, okProps} = useForm<TierFormState>({
initialState: {
...(tier || {}),
trial_days: tier?.trial_days?.toString() || '',
@ -56,6 +55,17 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
visibility: tier?.visibility || 'none',
welcome_page_url: tier?.welcome_page_url || null
},
savingDelay: 500,
savedDelay: 500,
onValidate: () => {
const newErrors: ErrorMessages = {};
Object.entries(validators).forEach(([key, validator]) => {
newErrors[key as keyof Tier] = validator?.();
});
return newErrors;
},
onSave: async () => {
const {trial_days: trialDays, currency, ...rest} = formState;
const values: Partial<Tier> = rest;
@ -72,16 +82,22 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
} else {
await createTier(values);
}
},
onSavedStateReset: () => {
modal.remove();
updateRoute('tiers');
},
onSaveError: handleError
});
const validators = {
name: () => setError('name', formState.name ? undefined : 'You must specify a name'),
monthly_price: () => formState.type !== 'free' && setError('monthly_price', validateCurrencyAmount(formState.monthly_price || 0, formState.currency, {allowZero: false})),
yearly_price: () => formState.type !== 'free' && setError('yearly_price', validateCurrencyAmount(formState.yearly_price || 0, formState.currency, {allowZero: false}))
const validateField = (key: string) => {
const error = validators[key as keyof Tier]?.();
if (error) {
setErrors({...errors, [key]: error});
} else {
clearError(key);
}
};
const benefits = useSortableIndexedList({
@ -105,8 +121,8 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
const didInitialRender = useRef(false);
useEffect(() => {
if (didInitialRender.current) {
validators.monthly_price();
validators.yearly_price();
validators.monthly_price?.();
validators.yearly_price?.();
}
didInitialRender.current = true;
@ -164,9 +180,11 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
afterClose={() => {
updateRoute('tiers');
}}
buttonsDisabled={okProps.disabled}
dirty={saveState === 'unsaved'}
leftButtonProps={leftButtonProps}
okLabel='Save & close'
okColor={okProps.color}
okLabel={okProps.label || 'Save & close'}
size='lg'
testId='tier-detail-modal'
title={(tier ? (tier.active ? 'Edit tier' : 'Edit archived tier') : 'New tier')}
@ -174,22 +192,13 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
onOk={async () => {
toast.remove();
if (Object.values(validators).filter(validator => validator()).length) {
if (!await handleSave({fakeWhenUnchanged: true})) {
showToast({
type: 'pageError',
message: 'Can\'t save tier, please double check that you\'ve filled all mandatory fields.'
});
return;
}
if (saveState !== 'unsaved') {
updateRoute('tiers');
modal.remove();
}
if (await handleSave()) {
updateRoute('tiers');
}
}}
>
<div className='-mb-8 mt-8 flex items-start gap-8'>
@ -203,7 +212,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
title='Name'
value={formState.name || ''}
autoFocus
onBlur={() => validators.name()}
onBlur={() => validateField('name')}
onChange={e => updateForm(state => ({...state, name: e.target.value}))}
/>}
<TextField
@ -243,7 +252,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
title='Monthly price'
valueInCents={formState.monthly_price || ''}
hideTitle
onBlur={() => validators.monthly_price()}
onBlur={() => validateField('monthly_price')}
onChange={price => updateForm(state => ({...state, monthly_price: price}))}
/>
<CurrencyField
@ -254,7 +263,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
title='Yearly price'
valueInCents={formState.yearly_price || ''}
hideTitle
onBlur={() => validators.yearly_price()}
onBlur={() => validateField('yearly_price')}
onChange={price => updateForm(state => ({...state, yearly_price: price}))}
/>
</div>

View file

@ -115,7 +115,7 @@ const Sidebar: React.FC<SidebarProps> = ({
const AnnouncementBarModal: React.FC = () => {
const {siteData} = useGlobalData();
const {localSettings, updateSetting, handleSave} = useSettingGroup();
const {localSettings, updateSetting, handleSave, okProps} = useSettingGroup({savingDelay: 500});
const [announcementContent] = getSettingValues<string>(localSettings, ['announcement_content']);
const [accentColor] = getSettingValues<string>(localSettings, ['accent_color']);
const [announcementBackgroundColor] = getSettingValues<string>(localSettings, ['announcement_background']);
@ -208,10 +208,12 @@ const AnnouncementBarModal: React.FC = () => {
afterClose={() => {
updateRoute('announcement-bar');
}}
buttonsDisabled={okProps.disabled}
cancelLabel='Close'
deviceSelector={true}
dirty={false}
okLabel='Save'
okColor={okProps.color}
okLabel={okProps.label || 'Save'}
preview={preview}
previewBgColor='greygradient'
previewToolbarTabs={previewTabs}
@ -221,8 +223,7 @@ const AnnouncementBarModal: React.FC = () => {
title='Announcement'
titleHeadingLevel={5}
onOk={async () => {
if (!(await handleSave())) {
updateRoute('announcement-bar');
if (!(await handleSave({fakeWhenUnchanged: true}))) {
showToast({
type: 'pageError',
message: 'An error occurred while saving your changes. Please try again.'

View file

@ -1,6 +1,4 @@
import BrandSettings, {BrandSettingValues} from './designAndBranding/BrandSettings';
// import Button from '../../../admin-x-ds/global/Button';
// import ChangeThemeModal from './ThemeModal';
import Icon from '../../../admin-x-ds/global/Icon';
import React, {useEffect, useState} from 'react';
import StickyFooter from '../../../admin-x-ds/global/StickyFooter';
@ -102,21 +100,23 @@ const DesignModal: React.FC = () => {
saveState,
handleSave,
updateForm,
setFormState
setFormState,
okProps
} = useForm({
initialState: {
settings: settings as Array<Setting & { dirty?: boolean }>,
themeSettings: themeSettings ? (themeSettings.custom_theme_settings as Array<CustomThemeSetting & { dirty?: boolean }>) : undefined
},
savingDelay: 500,
onSave: async () => {
if (formState.themeSettings?.some(setting => setting.dirty)) {
const response = await editThemeSettings(formState.themeSettings);
updateForm(state => ({...state, themeSettings: response.custom_theme_settings}));
setFormState(state => ({...state, themeSettings: response.custom_theme_settings}));
}
if (formState.settings.some(setting => setting.dirty)) {
const {settings: newSettings} = await editSettings(formState.settings.filter(setting => setting.dirty));
updateForm(state => ({...state, settings: newSettings}));
setFormState(state => ({...state, settings: newSettings}));
}
},
onSaveError: handleError
@ -215,12 +215,12 @@ const DesignModal: React.FC = () => {
afterClose={() => {
updateRoute('design');
}}
buttonsDisabled={saveState === 'saving'}
buttonsDisabled={okProps.disabled}
cancelLabel='Close'
defaultTab='homepage'
dirty={saveState === 'unsaved'}
okColor={saveState === 'saved' ? 'green' : 'black'}
okLabel={saveState === 'saved' ? 'Saved' : (saveState === 'saving' ? 'Saving...' : 'Save')}
okColor={okProps.color}
okLabel={okProps.label || 'Save'}
preview={previewContent}
previewToolbarTabs={previewTabs}
selectedURL={selectedPreviewTab}
@ -231,7 +231,7 @@ const DesignModal: React.FC = () => {
testId='design-modal'
title='Design'
onOk={async () => {
await handleSave();
await handleSave({fakeWhenUnchanged: true});
}}
onSelectURL={onSelectURL}
/>;

View file

@ -45,6 +45,7 @@ const NavigationModal = NiceModal.create(() => {
}}
buttonsDisabled={saveState === 'saving'}
dirty={localSettings.some(setting => setting.dirty)}
okLabel={saveState === 'saving' ? 'Saving...' : 'OK'}
scrolling={true}
size='lg'
stickyFooter={true}

View file

@ -1,3 +1,4 @@
import {ButtonColor} from '../admin-x-ds/global/Button';
import {useCallback, useEffect, useState} from 'react';
export type Dirtyable<Data> = Data & {
@ -8,13 +9,21 @@ export type SaveState = 'unsaved' | 'saving' | 'saved' | 'error' | '';
export type ErrorMessages = Record<string, string | undefined>
export interface OkProps {
disabled: boolean;
color: ButtonColor;
label?: string;
}
export type SaveHandler = (options?: {force?: boolean; fakeWhenUnchanged?: boolean}) => Promise<boolean>
export interface FormHook<State> {
formState: State;
saveState: SaveState;
/**
* Validate and save the state. Use the `force` option to save even when there are no changes made (e.g., initial state should be saveable)
*/
handleSave: (options?: {force?: boolean}) => Promise<boolean>;
handleSave: SaveHandler;
/**
* Update the form state and mark the form as dirty. Should be used in input events
*/
@ -30,26 +39,33 @@ export interface FormHook<State> {
isValid: boolean;
errors: ErrorMessages;
setErrors: (errors: ErrorMessages) => void;
okProps: OkProps;
}
const useForm = <State>({initialState, onSave, onSaveError, onValidate}: {
initialState: State,
onSave: (state: State) => void | Promise<void>
onSaveError?: (error: unknown) => void | Promise<void>
onValidate?: (state: State) => ErrorMessages
const useForm = <State>({initialState, savingDelay, savedDelay = 2000, onSave, onSaveError, onSavedStateReset: onSaveCompleted, onValidate}: {
initialState: State;
savingDelay?: number;
savedDelay?: number;
onSave: (state: State) => void | Promise<void>;
onSaveError?: (error: unknown) => void | Promise<void>;
onSavedStateReset?: () => void;
onValidate?: (state: State) => ErrorMessages;
}): FormHook<State> => {
const [formState, setFormState] = useState(initialState);
const [saveState, setSaveState] = useState<SaveState>('');
const [errors, setErrors] = useState<ErrorMessages>({});
// Reset saved state after 2 seconds
// Reset saved state after a delay
// To prevent infinite renders, uses the value of onSaveCompleted from when the form was saved
useEffect(() => {
if (saveState === 'saved') {
setTimeout(() => {
onSaveCompleted?.();
setSaveState(state => (state === 'saved' ? '' : state));
}, 2000);
}, savedDelay);
}
}, [saveState]);
}, [saveState, savedDelay]); // eslint-disable-line react-hooks/exhaustive-deps
const isValid = (errs: ErrorMessages) => Object.values(errs).filter(Boolean).length === 0;
@ -67,35 +83,51 @@ const useForm = <State>({initialState, onSave, onSaveError, onValidate}: {
);
// function to save the changed settings via API
const handleSave = useCallback(
async (options: {force?: boolean} = {}) => {
if (!validate()) {
return false;
}
const handleSave = useCallback<SaveHandler>(async (options = {}) => {
if (!validate()) {
return false;
}
if (saveState !== 'unsaved' && !options.force) {
return true;
}
if (saveState !== 'unsaved' && !options.force && !options.fakeWhenUnchanged) {
return true;
}
setSaveState('saving');
try {
const timeBefore = Date.now();
setSaveState('saving');
try {
if (saveState === 'unsaved' || options.force) {
await onSave(formState);
setSaveState('saved');
return true;
} catch (e) {
await onSaveError?.(e);
setSaveState('unsaved');
throw e;
}
},
[formState, saveState, onSave, onSaveError, validate]
);
const duration = Date.now() - timeBefore;
if (savingDelay && duration < savingDelay) {
await new Promise((resolve) => {
setTimeout(resolve, savingDelay - duration);
});
}
setSaveState('saved');
return true;
} catch (e) {
await onSaveError?.(e);
setSaveState('unsaved');
throw e;
}
}, [formState, saveState, savingDelay, onSave, onSaveError, validate]);
const updateForm = useCallback((updater: (state: State) => State) => {
setFormState(updater);
setSaveState('unsaved');
}, []);
const okProps: OkProps = {
disabled: saveState === 'saving',
color: saveState === 'saved' ? 'green' : 'black',
label: saveState === 'saved' ? 'Saved' : (saveState === 'saving' ? 'Saving...' : undefined)
};
return {
formState,
saveState,
@ -112,7 +144,8 @@ const useForm = <State>({initialState, onSave, onSaveError, onValidate}: {
setErrors(state => ({...state, [field]: ''}));
},
errors,
setErrors
setErrors,
okProps
};
};

View file

@ -1,5 +1,5 @@
import React, {useEffect, useRef, useState} from 'react';
import useForm, {ErrorMessages, SaveState} from './useForm';
import useForm, {ErrorMessages, OkProps, SaveHandler, SaveState} from './useForm';
import useGlobalDirtyState from './useGlobalDirtyState';
import useHandleError from '../utils/api/handleError';
import {Setting, SettingValue, useEditSettings} from '../api/settings';
@ -18,16 +18,17 @@ export interface SettingGroupHook {
saveState: SaveState;
siteData: SiteData | null;
focusRef: React.RefObject<HTMLInputElement>;
handleSave: () => Promise<boolean>;
handleSave: SaveHandler;
handleCancel: () => void;
updateSetting: (key: string, value: SettingValue) => void;
handleEditingChange: (newState: boolean) => void;
validate: () => boolean;
errors: ErrorMessages;
clearError: (key: string) => void;
okProps: OkProps;
}
const useSettingGroup = ({onValidate}: {onValidate?: () => ErrorMessages} = {}): SettingGroupHook => {
const useSettingGroup = ({savingDelay, onValidate}: {savingDelay?: number; onValidate?: () => ErrorMessages} = {}): SettingGroupHook => {
// create a ref to focus the input field
const focusRef = useRef<HTMLInputElement>(null);
@ -37,8 +38,9 @@ const useSettingGroup = ({onValidate}: {onValidate?: () => ErrorMessages} = {}):
const [isEditing, setEditing] = useState(false);
const {formState: localSettings, saveState, handleSave, updateForm, reset, validate, errors, clearError} = useForm<LocalSetting[]>({
const {formState: localSettings, saveState, handleSave, updateForm, setFormState, reset, validate, errors, clearError, okProps} = useForm<LocalSetting[]>({
initialState: settings || [],
savingDelay,
onSave: async () => {
await editSettings?.(changedSettings());
},
@ -61,7 +63,7 @@ const useSettingGroup = ({onValidate}: {onValidate?: () => ErrorMessages} = {}):
// reset the local state when there's a new settings API response, unless currently editing
useEffect(() => {
if (!isEditing || saveState === 'saving') {
reset();
setFormState(() => settings);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings]);
@ -124,7 +126,8 @@ const useSettingGroup = ({onValidate}: {onValidate?: () => ErrorMessages} = {}):
handleEditingChange,
validate,
errors,
clearError
clearError,
okProps
};
};