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 {mutateAsync: uploadImage} = useUploadImage();
const handleError = useHandleError(); const handleError = useHandleError();
const {formState, updateForm, handleSave, saveState, errors, clearError, validate} = useForm({ const {formState, updateForm, handleSave, saveState, errors, clearError, validate, okProps} = useForm({
initialState: integration, initialState: integration,
savingDelay: 500,
savedDelay: 500,
onSave: async () => { onSave: async () => {
await editIntegration(formState); await editIntegration(formState);
}, },
onSavedStateReset: () => {
modal.remove();
updateRoute('integrations');
},
onSaveError: handleError, onSaveError: handleError,
onValidate: () => { onValidate: () => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
@ -82,19 +88,17 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
afterClose={() => { afterClose={() => {
updateRoute('integrations'); updateRoute('integrations');
}} }}
buttonsDisabled={okProps.disabled}
dirty={saveState === 'unsaved'} dirty={saveState === 'unsaved'}
okColor='black' okColor={okProps.color}
okLabel='Save & close' okLabel={okProps.label || 'Save & close'}
size='md' size='md'
testId='custom-integration-modal' testId='custom-integration-modal'
title={formState.name} title={formState.name}
stickyFooter stickyFooter
onOk={async () => { onOk={async () => {
toast.remove(); toast.remove();
if (await handleSave()) { if (!(await handleSave({fakeWhenUnchanged: true}))) {
modal.remove();
updateRoute('integrations');
} else {
showToast({ showToast({
type: 'pageError', type: 'pageError',
message: 'Can\'t save integration, please double check that you\'ve filled all mandatory fields.' 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 {updateRoute} = useRouting();
const handleError = useHandleError(); 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, initialState: newsletter,
savingDelay: 500,
onSave: async () => { onSave: async () => {
const {newsletters, meta} = await editNewsletter(formState); const {newsletters, meta} = await editNewsletter(formState);
if (meta?.sent_email_verification) { if (meta?.sent_email_verification) {
@ -489,12 +490,12 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
return <PreviewModalContent return <PreviewModalContent
afterClose={() => updateRoute('newsletters')} afterClose={() => updateRoute('newsletters')}
buttonsDisabled={saveState === 'saving'} buttonsDisabled={okProps.disabled}
cancelLabel='Close' cancelLabel='Close'
deviceSelector={false} deviceSelector={false}
dirty={saveState === 'unsaved'} dirty={saveState === 'unsaved'}
okColor={saveState === 'saved' ? 'green' : 'black'} okColor={okProps.color}
okLabel={saveState === 'saved' ? 'Saved' : (saveState === 'saving' ? 'Saving...' : 'Save')} okLabel={okProps.label || 'Save'}
preview={preview} preview={preview}
previewBgColor={'grey'} previewBgColor={'grey'}
previewToolbar={false} previewToolbar={false}
@ -503,7 +504,7 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
testId='newsletter-modal' testId='newsletter-modal'
title='Newsletter' title='Newsletter'
onOk={async () => { onOk={async () => {
if (!(await handleSave())) { if (!(await handleSave({fakeWhenUnchanged: true}))) {
showToast({ showToast({
type: 'pageError', type: 'pageError',
message: 'Can\'t save newsletter, please double check that you\'ve filled all mandatory fields.' 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 NiceModal, {useModal} from '@ebay/nice-modal-react';
import ProfileBasics from './users/ProfileBasics'; import ProfileBasics from './users/ProfileBasics';
import ProfileDetails from './users/ProfileDetails'; import ProfileDetails from './users/ProfileDetails';
import React, {useCallback, useEffect, useState} from 'react'; import React, {useCallback, useEffect} from 'react';
import StaffToken from './users/StaffToken'; import StaffToken from './users/StaffToken';
import clsx from 'clsx'; import clsx from 'clsx';
import useForm, {ErrorMessages} from '../../../hooks/useForm';
import useHandleError from '../../../utils/api/handleError'; import useHandleError from '../../../utils/api/handleError';
import usePinturaEditor from '../../../hooks/usePinturaEditor'; import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useRouting from '../../../hooks/useRouting'; import useRouting from '../../../hooks/useRouting';
@ -28,11 +29,69 @@ import {toast} from 'react-hot-toast';
import {useGlobalData} from '../../providers/GlobalDataProvider'; import {useGlobalData} from '../../providers/GlobalDataProvider';
import {validateFacebookUrl, validateTwitterUrl} from '../../../utils/socialUrls'; 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 { export interface UserDetailProps {
user: User; user: User;
setUserData: (user: User) => void; setUserData: (user: User) => void;
errors: {[key in keyof User]?: string}; 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; clearError: (key: keyof User) => void;
} }
@ -47,15 +106,39 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
const {updateRoute} = useRouting(); const {updateRoute} = useRouting();
const {ownerUser} = useStaffUsers(); const {ownerUser} = useStaffUsers();
const {currentUser} = useGlobalData(); const {currentUser} = useGlobalData();
const [userData, _setUserData] = useState(user); const handleError = useHandleError();
const [saveState, setSaveState] = useState<'' | 'unsaved' | 'saving' | 'saved'>(''); const {formState, setFormState, saveState, handleSave, updateForm, errors, setErrors, clearError, okProps} = useForm({
const [errors, setErrors] = useState<UserDetailProps['errors']>({}); initialState: user,
savingDelay: 500,
const clearError = (key: keyof User) => setErrors(errs => ({...errs, [key]: undefined})); savedDelay: 500,
onValidate: (values) => {
const setUserData = (newUserData: User | ((current: User) => User)) => { return Object.entries(validators).reduce<ErrorMessages>((newErrors, [key, validate]) => {
_setUserData(newUserData); const error = validate(values);
setSaveState('unsaved'); 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(); const mainModal = useModal();
@ -64,7 +147,6 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
const {mutateAsync: deleteUser} = useDeleteUser(); const {mutateAsync: deleteUser} = useDeleteUser();
const {mutateAsync: makeOwner} = useMakeOwner(); const {mutateAsync: makeOwner} = useMakeOwner();
const limiter = useLimiter(); const limiter = useLimiter();
const handleError = useHandleError();
// Pintura integration // Pintura integration
const {settings} = useGlobalData(); const {settings} = useGlobalData();
@ -86,15 +168,6 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
} }
}, [currentUser, updateRoute]); }, [currentUser, updateRoute]);
useEffect(() => {
if (saveState === 'saved') {
setTimeout(() => {
mainModal.remove();
navigateOnClose();
}, 300);
}
}, [mainModal, navigateOnClose, saveState, updateRoute]);
const confirmSuspend = async (_user: User) => { const confirmSuspend = async (_user: User) => {
if (_user.status === 'inactive' && _user.roles[0].name !== 'Contributor') { if (_user.status === 'inactive' && _user.roles[0].name !== 'Contributor') {
try { try {
@ -133,7 +206,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
}; };
try { try {
await updateUser(updatedUserData); await updateUser(updatedUserData);
setUserData(updatedUserData); setFormState(() => updatedUserData);
modal?.remove(); modal?.remove();
showToast({ showToast({
message: _user.status === 'inactive' ? 'User un-suspended' : 'User suspended', message: _user.status === 'inactive' ? 'User un-suspended' : 'User suspended',
@ -201,12 +274,12 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
switch (image) { switch (image) {
case 'cover_image': case 'cover_image':
setUserData?.((_user) => { updateForm((_user) => {
return {..._user, cover_image: imageUrl}; return {..._user, cover_image: imageUrl};
}); });
break; break;
case 'profile_image': case 'profile_image':
setUserData?.((_user) => { updateForm((_user) => {
return {..._user, profile_image: imageUrl}; return {..._user, profile_image: imageUrl};
}); });
break; break;
@ -219,12 +292,12 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
const handleImageDelete = (image: string) => { const handleImageDelete = (image: string) => {
switch (image) { switch (image) {
case 'cover_image': case 'cover_image':
setUserData?.((_user) => { updateForm((_user) => {
return {..._user, cover_image: ''}; return {..._user, cover_image: ''};
}); });
break; break;
case 'profile_image': case 'profile_image':
setUserData?.((_user) => { updateForm((_user) => {
return {..._user, profile_image: ''}; return {..._user, profile_image: ''};
}); });
break; break;
@ -234,7 +307,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
const showMenu = hasAdminAccess(currentUser) || (isEditorUser(currentUser) && isAuthorOrContributor(user)); const showMenu = hasAdminAccess(currentUser) || (isEditorUser(currentUser) && isAuthorOrContributor(user));
let menuItems: MenuItem[] = []; let menuItems: MenuItem[] = [];
if (isOwnerUser(currentUser) && isAdminUser(userData) && userData.status !== 'inactive') { if (isOwnerUser(currentUser) && isAdminUser(formState) && formState.status !== 'inactive') {
menuItems.push({ menuItems.push({
id: 'make-owner', id: 'make-owner',
label: '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)) || (hasAdminAccess(currentUser) && !isOwnerUser(user)) ||
(isEditorUser(currentUser) && isAuthorOrContributor(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({ menuItems.push({
id: 'delete-user', id: 'delete-user',
@ -258,7 +331,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
id: 'suspend-user', id: 'suspend-user',
label: suspendUserLabel, label: suspendUserLabel,
onClick: () => { onClick: () => {
confirmSuspend(userData); confirmSuspend(formState);
} }
}); });
} }
@ -268,18 +341,10 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
label: 'View user activity', label: 'View user activity',
onClick: () => { onClick: () => {
mainModal.remove(); 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 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( const fileUploadButtonClasses = clsx(
@ -294,122 +359,35 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
coverEditButtonBaseClasses coverEditButtonBaseClasses
); );
const suspendedText = userData.status === 'inactive' ? ' (Suspended)' : ''; const suspendedText = formState.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;
}
}
};
return ( return (
<Modal <Modal
afterClose={navigateOnClose} afterClose={navigateOnClose}
animate={canAccessSettings(currentUser)} animate={canAccessSettings(currentUser)}
backDrop={canAccessSettings(currentUser)} backDrop={canAccessSettings(currentUser)}
buttonsDisabled={okProps.disabled}
dirty={saveState === 'unsaved'} dirty={saveState === 'unsaved'}
okLabel={okLabel} okColor={okProps.color}
okLabel={okProps.label || 'Save & close'}
size={canAccessSettings(currentUser) ? 'lg' : 'bleed'} size={canAccessSettings(currentUser) ? 'lg' : 'bleed'}
stickyFooter={true} stickyFooter={true}
testId='user-detail-modal' testId='user-detail-modal'
onOk={async () => { onOk={async () => {
setSaveState('saving');
let isValid = true;
if (Object.values(validators).map(validate => validate(userData)).includes(false)) {
isValid = false;
}
toast.remove(); toast.remove();
if (!isValid) { if (!(await handleSave({fakeWhenUnchanged: true}))) {
showToast({ showToast({
type: 'pageError', type: 'pageError',
message: 'Can\'t save user, please double check that you\'ve filled all mandatory fields.' message: 'Can\'t save user, please double check that you\'ve filled all mandatory fields.'
}); });
setSaveState('');
return;
} }
await updateUser?.(userData);
setSaveState('saved');
}} }}
> >
<div> <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={`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={{ <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 className='flex w-full max-w-[620px] flex-col gap-5 p-8 md:max-w-[auto] md:flex-row md:items-center'>
<div> <div>
@ -422,12 +400,12 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
id='avatar' id='avatar'
imageClassName='w-full h-full object-cover rounded-full shrink-0' 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' imageContainerClassName='relative group bg-cover bg-center -ml-2 h-[80px] w-[80px] shrink-0'
imageURL={userData.profile_image} imageURL={formState.profile_image}
pintura={ pintura={
{ {
isEnabled: editor.isEnabled, isEnabled: editor.isEnabled,
openEditor: async () => editor.openEditor({ openEditor: async () => editor.openEditor({
image: userData.profile_image || '', image: formState.profile_image || '',
handleSave: async (file:File) => { handleSave: async (file:File) => {
handleImageUpload('profile_image', file); handleImageUpload('profile_image', file);
} }
@ -460,12 +438,12 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
fileUploadClassName={fileUploadButtonClasses} fileUploadClassName={fileUploadButtonClasses}
id='cover-image' id='cover-image'
imageClassName='hidden' imageClassName='hidden'
imageURL={userData.cover_image || ''} imageURL={formState.cover_image || ''}
pintura={ pintura={
{ {
isEnabled: editor.isEnabled, isEnabled: editor.isEnabled,
openEditor: async () => editor.openEditor({ openEditor: async () => editor.openEditor({
image: userData.cover_image || '', image: formState.cover_image || '',
handleSave: async (file:File) => { handleSave: async (file:File) => {
handleImageUpload('cover_image', file); handleImageUpload('cover_image', file);
} }
@ -487,13 +465,13 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
</div> </div>
</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`}> <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'> <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 />} {user.id === currentUser.id && <StaffToken />}
</div> </div>
<EmailNotifications setUserData={setUserData} user={userData} /> <EmailNotifications setUserData={setUserData} user={formState} />
<ChangePasswordForm user={userData} /> <ChangePasswordForm user={formState} />
</div> </div>
</div> </div>
</Modal> </Modal>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import {ButtonColor} from '../admin-x-ds/global/Button';
import {useCallback, useEffect, useState} from 'react'; import {useCallback, useEffect, useState} from 'react';
export type Dirtyable<Data> = Data & { export type Dirtyable<Data> = Data & {
@ -8,13 +9,21 @@ export type SaveState = 'unsaved' | 'saving' | 'saved' | 'error' | '';
export type ErrorMessages = Record<string, string | undefined> 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> { export interface FormHook<State> {
formState: State; formState: State;
saveState: SaveState; 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) * 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 * 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; isValid: boolean;
errors: ErrorMessages; errors: ErrorMessages;
setErrors: (errors: ErrorMessages) => void; setErrors: (errors: ErrorMessages) => void;
okProps: OkProps;
} }
const useForm = <State>({initialState, onSave, onSaveError, onValidate}: { const useForm = <State>({initialState, savingDelay, savedDelay = 2000, onSave, onSaveError, onSavedStateReset: onSaveCompleted, onValidate}: {
initialState: State, initialState: State;
onSave: (state: State) => void | Promise<void> savingDelay?: number;
onSaveError?: (error: unknown) => void | Promise<void> savedDelay?: number;
onValidate?: (state: State) => ErrorMessages onSave: (state: State) => void | Promise<void>;
onSaveError?: (error: unknown) => void | Promise<void>;
onSavedStateReset?: () => void;
onValidate?: (state: State) => ErrorMessages;
}): FormHook<State> => { }): FormHook<State> => {
const [formState, setFormState] = useState(initialState); const [formState, setFormState] = useState(initialState);
const [saveState, setSaveState] = useState<SaveState>(''); const [saveState, setSaveState] = useState<SaveState>('');
const [errors, setErrors] = useState<ErrorMessages>({}); 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(() => { useEffect(() => {
if (saveState === 'saved') { if (saveState === 'saved') {
setTimeout(() => { setTimeout(() => {
onSaveCompleted?.();
setSaveState(state => (state === 'saved' ? '' : state)); 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; 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 // function to save the changed settings via API
const handleSave = useCallback( const handleSave = useCallback<SaveHandler>(async (options = {}) => {
async (options: {force?: boolean} = {}) => { if (!validate()) {
if (!validate()) { return false;
return false; }
}
if (saveState !== 'unsaved' && !options.force) { if (saveState !== 'unsaved' && !options.force && !options.fakeWhenUnchanged) {
return true; return true;
} }
setSaveState('saving'); const timeBefore = Date.now();
try {
setSaveState('saving');
try {
if (saveState === 'unsaved' || options.force) {
await onSave(formState); 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) => { const updateForm = useCallback((updater: (state: State) => State) => {
setFormState(updater); setFormState(updater);
setSaveState('unsaved'); setSaveState('unsaved');
}, []); }, []);
const okProps: OkProps = {
disabled: saveState === 'saving',
color: saveState === 'saved' ? 'green' : 'black',
label: saveState === 'saved' ? 'Saved' : (saveState === 'saving' ? 'Saving...' : undefined)
};
return { return {
formState, formState,
saveState, saveState,
@ -112,7 +144,8 @@ const useForm = <State>({initialState, onSave, onSaveError, onValidate}: {
setErrors(state => ({...state, [field]: ''})); setErrors(state => ({...state, [field]: ''}));
}, },
errors, errors,
setErrors setErrors,
okProps
}; };
}; };

View file

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