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:
parent
3680c16362
commit
92b57b4bdf
6 changed files with 143 additions and 71 deletions
|
@ -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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}));
|
||||||
|
|
|
@ -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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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’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’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);
|
||||||
|
|
|
@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue