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

Improved error handling in recommendation modals (#18524)

fixes https://github.com/TryGhost/Product/issues/3990

- Show errors on blur
- Show toast on submit (final modal only)
- Do an initial validation on mount
This commit is contained in:
Simon Backx 2023-10-06 14:23:18 +02:00 committed by GitHub
parent 3680c16362
commit 92b57b4bdf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 143 additions and 71 deletions

View file

@ -26,7 +26,7 @@ export const formatUrl = (value: string, baseUrl?: string) => {
if (!baseUrl) { if (!baseUrl) {
// Absolute URL with no base URL // Absolute URL with no base URL
if (!url.startsWith('http://') && !url.startsWith('https://')) { if (!url.startsWith('http')) {
url = `https://${url}`; url = `https://${url}`;
} }
} }

View file

@ -4,7 +4,7 @@ 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, useState} from 'react'; import React, {useEffect, useState} from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField'; import TextField from '../../../../admin-x-ds/global/form/TextField';
import useForm from '../../../../hooks/useForm'; import useForm, {ErrorMessages} from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {AlreadyExistsError} from '../../../../utils/errors'; import {AlreadyExistsError} from '../../../../utils/errors';
import {EditOrAddRecommendation, RecommendationResponseType, useGetRecommendationByUrl} from '../../../../api/recommendations'; import {EditOrAddRecommendation, RecommendationResponseType, useGetRecommendationByUrl} from '../../../../api/recommendations';
@ -25,6 +25,22 @@ const doFormatUrl = (url: string) => {
return formatUrl(url).save; return formatUrl(url).save;
}; };
const validateUrl = function (errors: ErrorMessages, url: string) {
try {
const u = new URL(url);
// Check domain includes a dot
if (!u.hostname.includes('.')) {
errors.url = 'Please enter a valid URL.';
} else {
delete errors.url;
}
} catch (e) {
errors.url = 'Please enter a valid URL.';
}
return errors;
};
const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModalProps> = ({searchParams, recommendation, animate}) => { const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModalProps> = ({searchParams, recommendation, animate}) => {
const [enterPressed, setEnterPressed] = useState(false); const [enterPressed, setEnterPressed] = useState(false);
const modal = useModal(); const modal = useModal();
@ -41,7 +57,7 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
const didInitialSubmit = React.useRef(false); const didInitialSubmit = React.useRef(false);
const [showLoadingView, setShowLoadingView] = React.useState(!!initialUrlCleaned); const [showLoadingView, setShowLoadingView] = React.useState(!!initialUrlCleaned);
const {formState, updateForm, handleSave, errors, saveState, clearError} = useForm({ const {formState, updateForm, handleSave, errors, saveState, clearError, setErrors} = useForm({
initialState: recommendation ?? { initialState: recommendation ?? {
title: '', title: '',
url: initialUrlCleaned, url: initialUrlCleaned,
@ -119,16 +135,7 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
onValidate: () => { onValidate: () => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
try { validateUrl(newErrors, formState.url);
const u = new URL(formState.url);
// Check domain includes a dot
if (!u.hostname.includes('.')) {
newErrors.url = 'Please enter a valid URL.';
}
} catch (e) {
newErrors.url = 'Please enter a valid URL.';
}
// If we have errors: close direct submit view // If we have errors: close direct submit view
if (showLoadingView) { if (showLoadingView) {
@ -225,7 +232,13 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
placeholder='https://www.example.com' placeholder='https://www.example.com'
title='URL' title='URL'
value={formState.url} value={formState.url}
onBlur={() => updateForm(state => ({...state, url: doFormatUrl(formState.url)}))} onBlur={() => {
const url = doFormatUrl(formState.url);
setErrors(
validateUrl(errors, url)
);
updateForm(state => ({...state, url: url}));
}}
onChange={(e) => { onChange={(e) => {
clearError?.('url'); clearError?.('url');
updateForm(state => ({...state, url: e.target.value})); updateForm(state => ({...state, url: e.target.value}));

View file

@ -2,7 +2,7 @@ import AddRecommendationModal from './AddRecommendationModal';
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 from 'react'; import React from 'react';
import RecommendationReasonForm from './RecommendationReasonForm'; import RecommendationReasonForm, {validateReasonForm} from './RecommendationReasonForm';
import useForm from '../../../../hooks/useForm'; import useForm 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';
@ -20,12 +20,12 @@ const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({r
const {mutateAsync: addRecommendation} = useAddRecommendation(); const {mutateAsync: addRecommendation} = useAddRecommendation();
const handleError = useHandleError(); const handleError = useHandleError();
const {formState, updateForm, handleSave, saveState, errors, clearError} = useForm({ const {formState, updateForm, handleSave, saveState, errors, clearError, setErrors} = useForm({
initialState: { initialState: {
...recommendation ...recommendation
}, },
onSave: async () => { onSave: async (state) => {
await addRecommendation(formState); await addRecommendation(state);
modal.remove(); modal.remove();
showToast({ showToast({
message: 'Successfully added a recommendation', message: 'Successfully added a recommendation',
@ -34,15 +34,16 @@ const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({r
updateRoute('recommendations'); updateRoute('recommendations');
}, },
onSaveError: handleError, onSaveError: handleError,
onValidate: () => { onValidate: (state) => {
const newErrors: Record<string, string> = {}; const newErrors = validateReasonForm(state);
if (!formState.title) {
newErrors.title = 'Title is required'; if (Object.keys(newErrors).length !== 0) {
showToast({
type: 'pageError',
message: 'Can\'t add recommendation, please double check that you\'ve filled all mandatory fields correctly.'
});
} }
if (formState.reason && formState.reason.length > 200) {
newErrors.reason = 'Description cannot be longer than 200 characters';
}
return newErrors; return newErrors;
} }
}); });
@ -122,7 +123,7 @@ const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({r
} }
}} }}
> >
<RecommendationReasonForm clearError={clearError} errors={errors} formState={formState} showURL={false} updateForm={updateForm}/> <RecommendationReasonForm clearError={clearError} errors={errors} formState={formState} setErrors={setErrors} showURL={false} updateForm={updateForm}/>
</Modal>; </Modal>;
}; };

View file

@ -2,7 +2,7 @@ import ConfirmationModal from '../../../../admin-x-ds/global/modal/ConfirmationM
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 from 'react'; import React from 'react';
import RecommendationReasonForm from './RecommendationReasonForm'; import RecommendationReasonForm, {validateReasonForm} from './RecommendationReasonForm';
import useForm from '../../../../hooks/useForm'; import useForm 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';
@ -22,25 +22,24 @@ 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} = useForm({ const {formState, updateForm, handleSave, saveState, errors, clearError, setErrors} = useForm({
initialState: { initialState: {
...recommendation ...recommendation
}, },
onSave: async () => { onSave: async (state) => {
await editRecommendation(formState); await editRecommendation(state);
modal.remove(); modal.remove();
updateRoute('recommendations'); updateRoute('recommendations');
}, },
onSaveError: handleError, onSaveError: handleError,
onValidate: () => { onValidate: (state) => {
const newErrors: Record<string, string> = {}; const newErrors = validateReasonForm(state);
if (!formState.title) { if (Object.keys(newErrors).length !== 0) {
newErrors.title = 'Title is required'; showToast({
} type: 'pageError',
message: 'Can\'t edit recommendation, please double check that you\'ve filled all mandatory fields correctly.'
if (formState.reason && formState.reason.length > 200) { });
newErrors.reason = 'Description cannot be longer than 200 characters';
} }
return newErrors; return newErrors;
@ -120,7 +119,7 @@ const EditRecommendationModal: React.FC<RoutingModalProps & EditRecommendationMo
} }
}} }}
> >
<RecommendationReasonForm clearError={clearError} errors={errors} formState={formState} showURL={true} updateForm={updateForm as any}/> <RecommendationReasonForm clearError={clearError} errors={errors} formState={formState} setErrors={setErrors} showURL={true} updateForm={updateForm as any}/>
</Modal>; </Modal>;
}; };

View file

@ -14,12 +14,56 @@ interface Props<T extends EditOrAddRecommendation> {
formState: T, formState: T,
errors: ErrorMessages, errors: ErrorMessages,
updateForm: (fn: (state: T) => T) => void, updateForm: (fn: (state: T) => T) => void,
clearError?: (key: keyof ErrorMessages) => void clearError?: (key: keyof ErrorMessages) => void,
setErrors: (errors: ErrorMessages) => void
} }
const RecommendationReasonForm: React.FC<Props<EditOrAddRecommendation | Recommendation>> = ({showURL, formState, updateForm, errors, clearError}) => { export const validateReasonFormField = function (errors: ErrorMessages, field: 'title'|'reason', value: string|null) {
const cloned = {...errors};
switch (field) {
case 'title':
if (!value) {
cloned.title = 'Title is required';
} else {
delete cloned.title;
}
break;
case 'reason':
if (value && value.length > 200) {
cloned.reason = 'Description cannot be longer than 200 characters';
} else {
delete cloned.reason;
}
break;
default:
// Will throw a compile error if we forget to add a case for a field
const f: never = field;
throw new Error(`Unknown field ${f}`);
}
return cloned;
};
export const validateReasonForm = function (formState: EditOrAddRecommendation) {
let newErrors: ErrorMessages = {};
newErrors = validateReasonFormField(newErrors, 'title', formState.title);
newErrors = validateReasonFormField(newErrors, 'reason', formState.reason);
return newErrors;
};
const RecommendationReasonForm: React.FC<Props<EditOrAddRecommendation | Recommendation>> = ({showURL, formState, updateForm, errors, clearError, setErrors}) => {
const [reasonLength, setReasonLength] = React.useState(formState?.reason?.length || 0); const [reasonLength, setReasonLength] = React.useState(formState?.reason?.length || 0);
const reasonLengthColor = reasonLength > 200 ? 'text-red' : 'text-green'; const reasonLengthColor = reasonLength > 200 ? 'text-red' : 'text-green';
// Do an intial validation on mounting
const didValidate = React.useRef(false);
React.useEffect(() => {
if (didValidate.current) {
return;
}
didValidate.current = true;
setErrors(validateReasonForm(formState));
}, [formState, setErrors]);
return <Form return <Form
marginBottom={false} marginBottom={false}
marginTop marginTop
@ -63,6 +107,9 @@ const RecommendationReasonForm: React.FC<Props<EditOrAddRecommendation | Recomme
hint={errors.title} hint={errors.title}
title="Title" title="Title"
value={formState.title ?? ''} value={formState.title ?? ''}
onBlur={() => setErrors(
validateReasonFormField(errors, 'title', formState.title)
)}
onChange={(e) => { onChange={(e) => {
clearError?.('title'); clearError?.('title');
updateForm(state => ({...state, title: e.target.value})); updateForm(state => ({...state, title: e.target.value}));
@ -71,10 +118,14 @@ const RecommendationReasonForm: React.FC<Props<EditOrAddRecommendation | Recomme
<TextArea <TextArea
clearBg={true} clearBg={true}
error={Boolean(errors.reason)} error={Boolean(errors.reason)}
hint={errors.reason || <>Max: <strong>200</strong> characters. You&#8217;ve used <strong className={reasonLengthColor}>{reasonLength}</strong></>} // Note: we don't show the error text here, because errors are related to the character count
hint={<>Max: <strong>200</strong> characters. You&#8217;ve used <strong className={reasonLengthColor}>{reasonLength}</strong></>}
rows={4} rows={4}
title="Short description" title="Short description"
value={formState.reason ?? ''} value={formState.reason ?? ''}
onBlur={() => setErrors(
validateReasonFormField(errors, 'reason', formState.reason)
)}
onChange={(e) => { onChange={(e) => {
clearError?.('reason'); clearError?.('reason');
setReasonLength(e.target.value.length); setReasonLength(e.target.value.length);

View file

@ -29,13 +29,14 @@ export interface FormHook<State> {
clearError: (field: string) => void; clearError: (field: string) => void;
isValid: boolean; isValid: boolean;
errors: ErrorMessages; errors: ErrorMessages;
setErrors: (errors: ErrorMessages) => void;
} }
const useForm = <State>({initialState, onSave, onSaveError, onValidate}: { const useForm = <State>({initialState, onSave, onSaveError, onValidate}: {
initialState: State, initialState: State,
onSave: () => void | Promise<void> onSave: (state: State) => void | Promise<void>
onSaveError?: (error: unknown) => void | Promise<void> onSaveError?: (error: unknown) => void | Promise<void>
onValidate?: () => ErrorMessages 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>('');
@ -52,37 +53,43 @@ const useForm = <State>({initialState, onSave, onSaveError, onValidate}: {
const isValid = (errs: ErrorMessages) => Object.values(errs).filter(Boolean).length === 0; const isValid = (errs: ErrorMessages) => Object.values(errs).filter(Boolean).length === 0;
const validate = () => { const validate = useCallback(
if (!onValidate) { () => {
return true; if (!onValidate) {
} return true;
}
const newErrors = onValidate(); const newErrors = onValidate(formState);
setErrors(newErrors); setErrors(newErrors);
return isValid(newErrors); return isValid(newErrors);
}; },
[formState, onValidate]
);
// function to save the changed settings via API // function to save the changed settings via API
const handleSave = async (options: {force?: boolean} = {}) => { const handleSave = useCallback(
if (!validate()) { async (options: {force?: boolean} = {}) => {
return false; if (!validate()) {
} return false;
}
if (saveState !== 'unsaved' && !options.force) { if (saveState !== 'unsaved' && !options.force) {
return true; return true;
} }
setSaveState('saving'); setSaveState('saving');
try { try {
await onSave(); await onSave(formState);
setSaveState('saved'); setSaveState('saved');
return true; return true;
} catch (e) { } catch (e) {
await onSaveError?.(e); await onSaveError?.(e);
setSaveState('unsaved'); setSaveState('unsaved');
throw e; throw e;
} }
}; },
[formState, saveState, onSave, onSaveError, validate]
);
const updateForm = useCallback((updater: (state: State) => State) => { const updateForm = useCallback((updater: (state: State) => State) => {
setFormState(updater); setFormState(updater);
@ -104,7 +111,8 @@ const useForm = <State>({initialState, onSave, onSaveError, onValidate}: {
clearError: (field: string) => { clearError: (field: string) => {
setErrors(state => ({...state, [field]: ''})); setErrors(state => ({...state, [field]: ''}));
}, },
errors errors,
setErrors
}; };
}; };