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

Added generic error handling to AdminX (#18274)

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

- Added API error classes and a generic error handling function
- Added retry logic to API requests matching the old admin
- Added the error handler to all queries and mutations
This commit is contained in:
Jono M 2023-09-21 17:02:26 +01:00 committed by GitHub
parent 96ecc73b17
commit 0625255a17
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 620 additions and 251 deletions

View file

@ -8,6 +8,7 @@ import NoValueLabel from '../../../admin-x-ds/global/NoValueLabel';
import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import handleError from '../../../utils/handleError';
import useRouting from '../../../hooks/useRouting';
import {ReactComponent as AmpIcon} from '../../../assets/icons/amp.svg';
import {ReactComponent as FirstPromoterIcon} from '../../../assets/icons/firstpromoter.svg';
@ -172,12 +173,16 @@ const CustomIntegrations: React.FC<{integrations: Integration[]}> = ({integratio
okColor: 'red',
okLabel: 'Delete Integration',
onOk: async (confirmModal) => {
await deleteIntegration(integration.id);
confirmModal?.remove();
showToast({
message: 'Integration deleted',
type: 'success'
});
try {
await deleteIntegration(integration.id);
confirmModal?.remove();
showToast({
message: 'Integration deleted',
type: 'success'
});
} catch (e) {
handleError(e);
}
}
});
}}

View file

@ -4,6 +4,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useEffect, useState} from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import handleError from '../../../../utils/handleError';
import useRouting from '../../../../hooks/useRouting';
import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter';
import {RoutingModalProps} from '../../../providers/RoutingProvider';
@ -40,9 +41,13 @@ const AddIntegrationModal: React.FC<RoutingModalProps> = () => {
testId='add-integration-modal'
title='Add integration'
onOk={async () => {
const data = await createIntegration({name});
modal.remove();
updateRoute({route: `integrations/show/${data.integrations[0].id}`});
try {
const data = await createIntegration({name});
modal.remove();
updateRoute({route: `integrations/show/${data.integrations[0].id}`});
} catch (e) {
handleError(e);
}
}}
>
<div className='mt-5'>

View file

@ -4,6 +4,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError';
import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/amp.svg';
import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings';
@ -30,7 +31,11 @@ const AmpModal = NiceModal.create(() => {
{key: 'amp', value: enabled},
{key: 'amp_gtag_id', value: trackingId}
];
await editSettings(updates);
try {
await editSettings(updates);
} catch (e) {
handleError(e);
}
};
return (
@ -81,4 +86,4 @@ const AmpModal = NiceModal.create(() => {
);
});
export default AmpModal;
export default AmpModal;

View file

@ -7,6 +7,7 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useEffect, useState} from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import WebhooksTable from './WebhooksTable';
import handleError from '../../../../utils/handleError';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import {APIKey, useRefreshAPIKey} from '../../../../api/apiKeys';
@ -30,6 +31,7 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
onSave: async () => {
await editIntegration(formState);
},
onSaveError: handleError,
onValidate: () => {
const newErrors: Record<string, string> = {};
@ -64,9 +66,13 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
prompt: `You can regenerate ${name} API Key any time, but any scripts or applications using it will need to be updated.`,
okLabel: `Regenerate ${name} API Key`,
onOk: async (confirmModal) => {
await refreshAPIKey({integrationId: integration.id, apiKeyId: apiKey.id});
setRegenerated(true);
confirmModal?.remove();
try {
await refreshAPIKey({integrationId: integration.id, apiKeyId: apiKey.id});
setRegenerated(true);
confirmModal?.remove();
} catch (e) {
handleError(e);
}
}
});
};
@ -104,8 +110,12 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
width='100px'
onDelete={() => updateForm(state => ({...state, icon_image: null}))}
onUpload={async (file) => {
const imageUrl = getImageUrl(await uploadImage({file}));
updateForm(state => ({...state, icon_image: imageUrl}));
try {
const imageUrl = getImageUrl(await uploadImage({file}));
updateForm(state => ({...state, icon_image: imageUrl}));
} catch (e) {
handleError(e);
}
}}
>
Upload icon

View file

@ -4,6 +4,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError';
import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/firstpromoter.svg';
import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings';
@ -53,9 +54,13 @@ const FirstpromoterModal = NiceModal.create(() => {
testId='firstpromoter-modal'
title=''
onOk={async () => {
await handleSave();
updateRoute('integrations');
modal.remove();
try {
await handleSave();
updateRoute('integrations');
modal.remove();
} catch (e) {
handleError(e);
}
}}
>
<IntegrationHeader

View file

@ -4,6 +4,7 @@ import IntegrationHeader from './IntegrationHeader';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError';
import pinturaScreenshot from '../../../../assets/images/pintura-screenshot.png';
import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/pintura.svg';
@ -68,10 +69,7 @@ const PinturaModal = NiceModal.create(() => {
});
} catch (e) {
setUploadingState({js: false, css: false});
showToast({
type: 'pageError',
message: `Can't upload Pintura ${form}!`
});
handleError(e);
}
};

View file

@ -3,6 +3,7 @@ import IntegrationHeader from './IntegrationHeader';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError';
import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as Icon} from '../../../../assets/icons/unsplash.svg';
import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings';
@ -19,7 +20,11 @@ const UnsplashModal = NiceModal.create(() => {
const updates: Setting[] = [
{key: 'unsplash', value: (e.target.checked)}
];
await editSettings(updates);
try {
await editSettings(updates);
} catch (error) {
handleError(error);
}
};
return (

View file

@ -4,6 +4,7 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react';
import Select from '../../../../admin-x-ds/global/form/Select';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import handleError from '../../../../utils/handleError';
import toast from 'react-hot-toast';
import useForm from '../../../../hooks/useForm';
import validator from 'validator';
@ -30,6 +31,7 @@ const WebhookModal: React.FC<WebhookModalProps> = ({webhook, integrationId}) =>
await createWebhook({...formState, integration_id: integrationId});
}
},
onSaveError: handleError,
onValidate: () => {
const newErrors: Record<string, string> = {};

View file

@ -6,6 +6,7 @@ import TableCell from '../../../../admin-x-ds/global/TableCell';
import TableHead from '../../../../admin-x-ds/global/TableHead';
import TableRow from '../../../../admin-x-ds/global/TableRow';
import WebhookModal from './WebhookModal';
import handleError from '../../../../utils/handleError';
import {Integration} from '../../../../api/integrations';
import {getWebhookEventLabel} from './webhookEventOptions';
import {showToast} from '../../../../admin-x-ds/global/Toast';
@ -21,12 +22,16 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => {
okColor: 'red',
okLabel: 'Delete Webhook',
onOk: async (confirmModal) => {
await deleteWebhook(id);
confirmModal?.remove();
showToast({
message: 'Webhook deleted',
type: 'success'
});
try {
await deleteWebhook(id);
confirmModal?.remove();
showToast({
message: 'Webhook deleted',
type: 'success'
});
} catch (e) {
handleError(e);
}
}
});
};

View file

@ -6,6 +6,7 @@ import List from '../../../../admin-x-ds/global/List';
import ListItem from '../../../../admin-x-ds/global/ListItem';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import handleError from '../../../../utils/handleError';
import useRouting from '../../../../hooks/useRouting';
import {ReactComponent as ArrowRightIcon} from '../../../../admin-x-ds/assets/icons/arrow-right.svg';
import {ReactComponent as Icon} from '../../../../assets/icons/zapier.svg';
@ -57,9 +58,13 @@ const ZapierModal = NiceModal.create(() => {
prompt: 'You will need to locate the Ghost App within your Zapier account and click on "Reconnect" to enter the new Admin API Key.',
okLabel: 'Regenerate Admin API Key',
onOk: async (confirmModal) => {
await refreshAPIKey({integrationId: integration.id, apiKeyId: adminApiKey.id});
setRegenerated(true);
confirmModal?.remove();
try {
await refreshAPIKey({integrationId: integration.id, apiKeyId: adminApiKey.id});
setRegenerated(true);
confirmModal?.remove();
} catch (e) {
handleError(e);
}
}
});
};

View file

@ -4,6 +4,7 @@ import FileUpload from '../../../../admin-x-ds/global/form/FileUpload';
import LabItem from './LabItem';
import List from '../../../../admin-x-ds/global/List';
import React, {useState} from 'react';
import handleError from '../../../../utils/handleError';
import useRouting from '../../../../hooks/useRouting';
import {downloadRedirects, useUploadRedirects} from '../../../../api/redirects';
import {downloadRoutes, useUploadRoutes} from '../../../../api/routes';
@ -35,13 +36,18 @@ const BetaFeatures: React.FC = () => {
<FileUpload
id='upload-redirects'
onUpload={async (file) => {
setRedirectsUploading(true);
await uploadRedirects(file);
showToast({
type: 'success',
message: 'Redirects uploaded successfully'
});
setRedirectsUploading(false);
try {
setRedirectsUploading(true);
await uploadRedirects(file);
showToast({
type: 'success',
message: 'Redirects uploaded successfully'
});
} catch (e) {
handleError(e);
} finally {
setRedirectsUploading(false);
}
}}
>
<Button color='grey' label={redirectsUploading ? 'Uploading ...' : 'Upload redirects file'} size='sm' tag='div' />
@ -55,13 +61,18 @@ const BetaFeatures: React.FC = () => {
<FileUpload
id='upload-routes'
onUpload={async (file) => {
setRoutesUploading(true);
await uploadRoutes(file);
showToast({
type: 'success',
message: 'Routes uploaded successfully'
});
setRoutesUploading(false);
try {
setRoutesUploading(true);
await uploadRoutes(file);
showToast({
type: 'success',
message: 'Routes uploaded successfully'
});
} catch (e) {
handleError(e);
} finally {
setRoutesUploading(false);
}
}}
>
<Button color='grey' label={routesUploading ? 'Uploading ...' : 'Upload routes file'} size='sm' tag='div' />

View file

@ -1,5 +1,6 @@
import React from 'react';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError';
import {ConfigResponseType, configDataType} from '../../../../api/config';
import {getSettingValue, useEditSettings} from '../../../../api/settings';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
@ -15,20 +16,24 @@ const FeatureToggle: React.FC<{ flag: string; }> = ({flag}) => {
return <Toggle checked={labs[flag]} onChange={async () => {
const newValue = !labs[flag];
await editSettings([{
key: 'labs',
value: JSON.stringify({...labs, [flag]: newValue})
}]);
toggleFeatureFlag(flag, newValue);
client.setQueriesData([configDataType], current => ({
config: {
...(current as ConfigResponseType).config,
labs: {
...(current as ConfigResponseType).config.labs,
[flag]: newValue
try {
await editSettings([{
key: 'labs',
value: JSON.stringify({...labs, [flag]: newValue})
}]);
toggleFeatureFlag(flag, newValue);
client.setQueriesData([configDataType], current => ({
config: {
...(current as ConfigResponseType).config,
labs: {
...(current as ConfigResponseType).config.labs,
[flag]: newValue
}
}
}
}));
}));
} catch (e) {
handleError(e);
}
}} />;
};

View file

@ -5,6 +5,7 @@ import LabItem from './LabItem';
import List from '../../../../admin-x-ds/global/List';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import handleError from '../../../../utils/handleError';
import {downloadAllContent, useDeleteAllContent, useImportContent} from '../../../../api/db';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {useQueryClient} from '@tanstack/react-query';
@ -18,16 +19,22 @@ const ImportModalContent = () => {
id="import-file"
onUpload={async (file) => {
setUploading(true);
await importContent(file);
modal.remove();
NiceModal.show(ConfirmationModal, {
title: 'Import in progress',
prompt: `Your import is being processed, and you'll receive a confirmation email as soon as it's complete. Usually this only takes a few minutes, but larger imports may take longer.`,
cancelLabel: '',
okLabel: 'Got it',
onOk: confirmModal => confirmModal?.remove(),
formSheet: false
});
try {
await importContent(file);
modal.remove();
NiceModal.show(ConfirmationModal, {
title: 'Import in progress',
prompt: `Your import is being processed, and you'll receive a confirmation email as soon as it's complete. Usually this only takes a few minutes, but larger imports may take longer.`,
cancelLabel: '',
okLabel: 'Got it',
onOk: confirmModal => confirmModal?.remove(),
formSheet: false
});
} catch (e) {
handleError(e);
} finally {
setUploading(false);
}
}}
>
<div className="cursor-pointer bg-grey-75 p-10 text-center dark:bg-grey-950">
@ -56,12 +63,16 @@ const MigrationOptions: React.FC = () => {
okColor: 'red',
okLabel: 'Delete',
onOk: async () => {
await deleteAllContent(null);
showToast({
type: 'success',
message: 'All content deleted from database.'
});
await client.refetchQueries();
try {
await deleteAllContent(null);
showToast({
type: 'success',
message: 'All content deleted from database.'
});
await client.refetchQueries();
} catch (e) {
handleError(e);
}
}
});
};

View file

@ -4,6 +4,7 @@ import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import Toggle from '../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../utils/handleError';
import useRouting from '../../../hooks/useRouting';
import {Setting, getSettingValues, useEditSettings} from '../../../api/settings';
import {useGlobalData} from '../../providers/GlobalDataProvider';
@ -27,7 +28,11 @@ const EnableNewsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
updates.push({key: 'editor_default_email_recipients_filter', value: null});
}
await editSettings(updates);
try {
await editSettings(updates);
} catch (error) {
handleError(error);
}
};
const enableToggle = (

View file

@ -5,6 +5,7 @@ import Select from '../../../admin-x-ds/global/form/Select';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../admin-x-ds/global/form/TextField';
import handleError from '../../../utils/handleError';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {getSettingValues, useEditSettings} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
@ -113,7 +114,12 @@ const MailGun: React.FC<{ keywords: string[] }> = ({keywords}) => {
// resulting in the mailgun base url remaining null
// this should not fire if the user has changed the region or if the region is already set
if (!mailgunRegion) {
await editSettings([{key: 'mailgun_base_url', value: MAILGUN_REGIONS[0].value}]);
try {
await editSettings([{key: 'mailgun_base_url', value: MAILGUN_REGIONS[0].value}]);
} catch (e) {
handleError(e);
return;
}
}
handleSave();
}}

View file

@ -6,6 +6,7 @@ import React, {useEffect} from 'react';
import TextArea from '../../../../admin-x-ds/global/form/TextArea';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter';
@ -39,6 +40,7 @@ const AddNewsletterModal: React.FC<RoutingModalProps> = () => {
updateRoute({route: `newsletters/show/${response.newsletters[0].id}`});
},
onSaveError: handleError,
onValidate: () => {
const newErrors: Record<string, string> = {};

View file

@ -19,6 +19,7 @@ import TextArea from '../../../../admin-x-ds/global/form/TextArea';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import ToggleGroup from '../../../../admin-x-ds/global/form/ToggleGroup';
import handleError from '../../../../utils/handleError';
import useFeatureFlag from '../../../../hooks/useFeatureFlag';
import useForm, {ErrorMessages} from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
@ -84,12 +85,16 @@ const Sidebar: React.FC<{
okLabel: 'Archive',
okColor: 'red',
onOk: async (modal) => {
await editNewsletter({...newsletter, status: 'archived'});
modal?.remove();
showToast({
type: 'success',
message: 'Newsletter archived successfully'
});
try {
await editNewsletter({...newsletter, status: 'archived'});
modal?.remove();
showToast({
type: 'success',
message: 'Newsletter archived successfully'
});
} catch (e) {
handleError(e);
}
}
});
} else {
@ -212,8 +217,12 @@ const Sidebar: React.FC<{
updateNewsletter({header_image: null});
}}
onUpload={async (file) => {
const imageUrl = getImageUrl(await uploadImage({file}));
updateNewsletter({header_image: imageUrl});
try {
const imageUrl = getImageUrl(await uploadImage({file}));
updateNewsletter({header_image: imageUrl});
} catch (e) {
handleError(e);
}
}}
>
<Icon colorClass='text-grey-700 dark:text-grey-300' name='picture' />
@ -447,6 +456,7 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
modal.remove();
}
},
onSaveError: handleError,
onValidate: () => {
const newErrors: Record<string, string> = {};

View file

@ -13,52 +13,7 @@ interface NewslettersListProps {
}
const NewsletterItem: React.FC<{newsletter: Newsletter}> = ({newsletter}) => {
// const {mutateAsync: editNewsletter} = useEditNewsletter();
const {updateRoute} = useRouting();
// const limiter = useLimiter();
// const action = newsletter.status === 'active' ? (
// <Button color='red' disabled={onlyOne} label='Archive' link onClick={() => {
// NiceModal.show(ConfirmationModal, {
// title: 'Archive newsletter',
// prompt: <>
// <p>Your newsletter <strong>{newsletter.name}</strong> will no longer be visible to members or available as an option when publishing new posts.</p>
// <p>Existing posts previously sent as this newsletter will remain unchanged.</p>
// </>,
// okLabel: 'Archive',
// onOk: async (modal) => {
// await editNewsletter({...newsletter, status: 'archived'});
// modal?.remove();
// }
// });
// }} />
// ) : (
// <Button color='green' label='Activate' link onClick={async () => {
// try {
// await limiter?.errorIfWouldGoOverLimit('newsletters');
// } catch (error) {
// if (error instanceof HostLimitError) {
// NiceModal.show(LimitModal, {
// prompt: error.message || `Your current plan doesn't support more newsletters.`
// });
// return;
// } else {
// throw error;
// }
// }
// NiceModal.show(ConfirmationModal, {
// title: 'Reactivate newsletter',
// prompt: <>
// Reactivating <strong>{newsletter.name}</strong> will immediately make it visible to members and re-enable it as an option when publishing new posts.
// </>,
// okLabel: 'Reactivate',
// onOk: async (modal) => {
// await editNewsletter({...newsletter, status: 'active'});
// modal?.remove();
// }
// });
// }} />
// );
const showDetails = () => {
updateRoute({route: `newsletters/show/${newsletter.id}`});

View file

@ -3,6 +3,7 @@ import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../admin-x-ds/global/form/TextField';
import handleError from '../../../utils/handleError';
import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {ReactComponent as FacebookLogo} from '../../../admin-x-ds/assets/images/facebook-logo.svg';
@ -52,8 +53,12 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
};
const handleImageUpload = async (file: File) => {
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('og_image', imageUrl);
try {
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('og_image', imageUrl);
} catch (e) {
handleError(e);
}
};
const handleImageDelete = () => {

View file

@ -2,6 +2,7 @@ import Modal from '../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import Radio from '../../../admin-x-ds/global/form/Radio';
import TextField from '../../../admin-x-ds/global/form/TextField';
import handleError from '../../../utils/handleError';
import useRouting from '../../../hooks/useRouting';
import validator from 'validator';
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
@ -119,6 +120,7 @@ const InviteUserModal = NiceModal.create(() => {
message: `Failed to send invitation to ${email}`,
type: 'error'
});
handleError(e, {withToast: false});
return;
}
};

View file

@ -3,6 +3,7 @@ import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../admin-x-ds/global/form/TextField';
import handleError from '../../../utils/handleError';
import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {ReactComponent as TwitterLogo} from '../../../admin-x-ds/assets/images/twitter-logo.svg';
@ -53,8 +54,8 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
try {
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('twitter_image', imageUrl);
} catch (err) {
// TODO: handle error
} catch (e) {
handleError(e);
}
};

View file

@ -15,6 +15,7 @@ import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupConten
import TextField from '../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../admin-x-ds/global/form/Toggle';
import clsx from 'clsx';
import handleError from '../../../utils/handleError';
import useFeatureFlag from '../../../hooks/useFeatureFlag';
import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useRouting from '../../../hooks/useRouting';
@ -278,9 +279,13 @@ const StaffToken: React.FC<UserDetailProps> = () => {
okLabel: 'Regenerate your Staff Access Token',
okColor: 'red',
onOk: async (modal) => {
const newAPI = await newApiKey([]);
setToken(newAPI?.apiKey?.secret || '');
modal?.remove();
try {
const newAPI = await newApiKey([]);
setToken(newAPI?.apiKey?.secret || '');
modal?.remove();
} catch (e) {
handleError(e);
}
}
});
};
@ -395,13 +400,17 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
..._user,
status: _user.status === 'inactive' ? 'active' : 'inactive'
};
await updateUser(updatedUserData);
setUserData(updatedUserData);
modal?.remove();
showToast({
message: _user.status === 'inactive' ? 'User un-suspended' : 'User suspended',
type: 'success'
});
try {
await updateUser(updatedUserData);
setUserData(updatedUserData);
modal?.remove();
showToast({
message: _user.status === 'inactive' ? 'User un-suspended' : 'User suspended',
type: 'success'
});
} catch (e) {
handleError(e);
}
}
});
};
@ -418,13 +427,17 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
okLabel: 'Delete user',
okColor: 'red',
onOk: async (modal) => {
await deleteUser(_user?.id);
modal?.remove();
mainModal?.remove();
showToast({
message: 'User deleted',
type: 'success'
});
try {
await deleteUser(_user?.id);
modal?.remove();
mainModal?.remove();
showToast({
message: 'User deleted',
type: 'success'
});
} catch (e) {
handleError(e);
}
}
});
};
@ -436,12 +449,16 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
okLabel: 'Yep — I\'m sure',
okColor: 'red',
onOk: async (modal) => {
await makeOwner(user.id);
modal?.remove();
showToast({
message: 'Ownership transferred',
type: 'success'
});
try {
await makeOwner(user.id);
modal?.remove();
showToast({
message: 'Ownership transferred',
type: 'success'
});
} catch (e) {
handleError(e);
}
}
});
};
@ -462,8 +479,8 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
});
break;
}
} catch (err) {
// TODO: handle error
} catch (e) {
handleError(e);
}
};

View file

@ -7,6 +7,7 @@ import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import clsx from 'clsx';
import handleError from '../../../utils/handleError';
import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
import {User, hasAdminAccess, isContributorUser, isEditorUser} from '../../../api/users';
@ -127,13 +128,18 @@ const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
label={revokeActionLabel}
link={true}
onClick={async () => {
setRevokeState('progress');
await deleteInvite(invite.id);
setRevokeState('');
showToast({
message: `Invitation revoked (${invite.email})`,
type: 'success'
});
try {
setRevokeState('progress');
await deleteInvite(invite.id);
showToast({
message: `Invitation revoked (${invite.email})`,
type: 'success'
});
} catch (e) {
handleError(e);
} finally {
setRevokeState('');
}
}}
/>
<Button
@ -142,17 +148,22 @@ const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
label={resendActionLabel}
link={true}
onClick={async () => {
setResendState('progress');
await deleteInvite(invite.id);
await addInvite({
email: invite.email,
roleId: invite.role_id
});
setResendState('');
showToast({
message: `Invitation resent! (${invite.email})`,
type: 'success'
});
try {
setResendState('progress');
await deleteInvite(invite.id);
await addInvite({
email: invite.email,
roleId: invite.role_id
});
showToast({
message: `Invitation resent! (${invite.email})`,
type: 'success'
});
} catch (e) {
handleError(e);
} finally {
setResendState('');
}
}}
/>
</div>

View file

@ -2,6 +2,7 @@ import Button from '../../../../admin-x-ds/global/Button';
import Heading from '../../../../admin-x-ds/global/Heading';
import SettingGroup from '../../../../admin-x-ds/settings/SettingGroup';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import handleError from '../../../../utils/handleError';
import {User, useUpdatePassword} from '../../../../api/users';
import {ValidationError} from '../../../../utils/errors';
import {showToast} from '../../../../admin-x-ds/global/Toast';
@ -223,6 +224,7 @@ const ChangePasswordForm: React.FC<{user: User}> = ({user}) => {
type: 'pageError',
message: e instanceof ValidationError ? e.message : `Couldn't update password. Please try again.`
});
handleError(e, {withToast: false});
}
}}
/>

View file

@ -7,6 +7,7 @@ import Select from '../../../../admin-x-ds/global/form/Select';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import clsx from 'clsx';
import handleError from '../../../../utils/handleError';
import {ReactComponent as PortalIcon1} from '../../../../assets/icons/portal-icon-1.svg';
import {ReactComponent as PortalIcon2} from '../../../../assets/icons/portal-icon-2.svg';
import {ReactComponent as PortalIcon3} from '../../../../assets/icons/portal-icon-3.svg';
@ -52,9 +53,13 @@ const LookAndFeel: React.FC<{
const [uploadedIcon, setUploadedIcon] = useState(isDefaultIcon ? undefined : currentIcon);
const handleImageUpload = async (file: File) => {
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('portal_button_icon', imageUrl);
setUploadedIcon(imageUrl);
try {
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('portal_button_icon', imageUrl);
setUploadedIcon(imageUrl);
} catch (e) {
handleError(e);
}
};
const handleImageDelete = () => {

View file

@ -6,14 +6,14 @@ import PortalPreview from './PortalPreview';
import React, {useEffect, useState} from 'react';
import SignupOptions from './SignupOptions';
import TabView, {Tab} from '../../../../admin-x-ds/global/TabView';
import handleError from '../../../../utils/handleError';
import useForm, {Dirtyable} from '../../../../hooks/useForm';
import useQueryParams from '../../../../hooks/useQueryParams';
import useRouting from '../../../../hooks/useRouting';
import {PreviewModalContent} from '../../../../admin-x-ds/global/modal/PreviewModal';
import {Setting, SettingValue, useEditSettings} from '../../../../api/settings';
import {Setting, SettingValue, getSettingValues, useEditSettings} from '../../../../api/settings';
import {Tier, getPaidActiveTiers, useBrowseTiers, useEditTier} from '../../../../api/tiers';
import {fullEmailAddress} from '../../../../api/site';
import {getSettingValues} from '../../../../api/settings';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
import {verifyEmailToken} from '../../../../api/emailVerification';
@ -106,6 +106,7 @@ const PortalModal: React.FC = () => {
cancelLabel: '',
onOk: confirmModal => confirmModal?.remove()
});
handleError(e, {withToast: false});
}
};
if (verifyEmail) {
@ -139,7 +140,9 @@ const PortalModal: React.FC = () => {
onOk: confirmModal => confirmModal?.remove()
});
}
}
},
onSaveError: handleError
});
const [errors, setErrors] = useState<Record<string, string | undefined>>({});

View file

@ -13,6 +13,7 @@ import StripeLogo from '../../../../assets/images/stripe-emblem.svg';
import TextArea from '../../../../admin-x-ds/global/form/TextArea';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError';
import useRouting from '../../../../hooks/useRouting';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import {JSONError} from '../../../../utils/errors';
@ -86,7 +87,8 @@ const Connect: React.FC = () => {
// no-op: will try saving again as stripe is not ready
continue;
} else {
throw e;
handleError(e);
return;
}
}
}
@ -111,8 +113,10 @@ const Connect: React.FC = () => {
if (e instanceof JSONError && e.data?.errors) {
setError('Invalid secure key');
return;
} else {
handleError(e);
return;
}
throw error;
}
} else {
setError('Please enter a secure key');
@ -169,9 +173,13 @@ const Connected: React.FC<{onClose?: () => void}> = ({onClose}) => {
from this site. This will automatically turn off paid memberships on this site.</>),
okLabel: hasActiveStripeSubscriptions ? '' : 'Disconnect',
onOk: async (modal) => {
await deleteStripeSettings(null);
modal?.remove();
onClose?.();
try {
await deleteStripeSettings(null);
modal?.remove();
onClose?.();
} catch (e) {
handleError(e);
}
}
});
};

View file

@ -12,6 +12,7 @@ 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 handleError from '../../../../utils/handleError';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import useSettingGroup from '../../../../hooks/useSettingGroup';
@ -69,7 +70,8 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
}
modal.remove();
}
},
onSaveError: handleError
});
const validators = {

View file

@ -7,6 +7,7 @@ import StickyFooter from '../../../admin-x-ds/global/StickyFooter';
import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
import ThemePreview from './designAndBranding/ThemePreview';
import ThemeSettings from './designAndBranding/ThemeSettings';
import handleError from '../../../utils/handleError';
import useForm from '../../../hooks/useForm';
import useRouting from '../../../hooks/useRouting';
import {CustomThemeSetting, useBrowseCustomThemeSettings, useEditCustomThemeSettings} from '../../../api/customThemeSettings';
@ -116,7 +117,8 @@ const DesignModal: React.FC = () => {
const {settings: newSettings} = await editSettings(formState.settings.filter(setting => setting.dirty));
updateForm(state => ({...state, settings: newSettings}));
}
}
},
onSaveError: handleError
});
useEffect(() => {

View file

@ -12,9 +12,10 @@ import React, {useEffect, useState} from 'react';
import TabView from '../../../admin-x-ds/global/TabView';
import ThemeInstalledModal from './theme/ThemeInstalledModal';
import ThemePreview from './theme/ThemePreview';
import handleError from '../../../utils/handleError';
import useRouting from '../../../hooks/useRouting';
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
import {InstalledTheme, Theme, useBrowseThemes, useInstallTheme, useUploadTheme} from '../../../api/themes';
import {InstalledTheme, Theme, ThemesInstallResponseType, useBrowseThemes, useInstallTheme, useUploadTheme} from '../../../api/themes';
import {OfficialTheme} from '../../providers/ServiceProvider';
interface ThemeToolbarProps {
@ -71,7 +72,18 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
file: File;
onActivate?: () => void
}) => {
const data = await uploadTheme({file});
let data: ThemesInstallResponseType | undefined;
try {
data = await uploadTheme({file});
} catch (e) {
handleError(e);
}
if (!data) {
return;
}
const uploadedTheme = data.themes[0];
let title = 'Upload successful';
@ -247,8 +259,18 @@ const ChangeThemeModal = () => {
prompt = <>By clicking below, <strong>{selectedTheme.name}</strong> will automatically be activated as the theme for your site.</>;
} else {
setInstalling(true);
const data = await installTheme(selectedTheme.ref);
setInstalling(false);
let data: ThemesInstallResponseType | undefined;
try {
data = await installTheme(selectedTheme.ref);
} catch (e) {
handleError(e);
} finally {
setInstalling(false);
}
if (!data) {
return;
}
const newlyInstalledTheme = data.themes[0];

View file

@ -6,6 +6,7 @@ import React, {useRef, useState} from 'react';
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import UnsplashSearchModal from '../../../../utils/unsplash/UnsplashSearchModal';
import handleError from '../../../../utils/handleError';
import usePinturaEditor from '../../../../hooks/usePinturaEditor';
import {SettingValue, getSettingValues} from '../../../../api/settings';
import {debounce} from '../../../../utils/debounce';
@ -91,7 +92,11 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
width={values.icon ? '66px' : '150px'}
onDelete={() => updateSetting('icon', null)}
onUpload={async (file) => {
updateSetting('icon', getImageUrl(await uploadImage({file})));
try {
updateSetting('icon', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
}
}}
>
Upload icon
@ -109,7 +114,11 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
imageURL={values.logo || ''}
onDelete={() => updateSetting('logo', null)}
onUpload={async (file) => {
updateSetting('logo', getImageUrl(await uploadImage({file})));
try {
updateSetting('logo', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
}
}}
>
Upload logo
@ -130,7 +139,11 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
openEditor: async () => editor.openEditor({
image: values.coverImage || '',
handleSave: async (file:File) => {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
try {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
}
}
})
}
@ -139,7 +152,11 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
unsplashEnabled={true}
onDelete={() => updateSetting('cover_image', null)}
onUpload={async (file) => {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
try {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
}
}}
>
Upload cover

View file

@ -7,6 +7,7 @@ import Select from '../../../../admin-x-ds/global/form/Select';
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import handleError from '../../../../utils/handleError';
import {CustomThemeSetting} from '../../../../api/customThemeSettings';
import {getImageUrl, useUploadImage} from '../../../../api/images';
import {humanizeSettingKey} from '../../../../api/settings';
@ -18,8 +19,12 @@ const ThemeSetting: React.FC<{
const {mutateAsync: uploadImage} = useUploadImage();
const handleImageUpload = async (file: File) => {
const imageUrl = getImageUrl(await uploadImage({file}));
setSetting(imageUrl);
try {
const imageUrl = getImageUrl(await uploadImage({file}));
setSetting(imageUrl);
} catch (e) {
handleError(e);
}
};
switch (setting.type) {

View file

@ -3,6 +3,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react';
import RecommendationReasonForm from './RecommendationReasonForm';
import handleError from '../../../../utils/handleError';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import {EditOrAddRecommendation, useAddRecommendation} from '../../../../api/recommendations';
@ -31,6 +32,7 @@ const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({r
});
updateRoute('recommendations');
},
onSaveError: handleError,
onValidate: () => {
const newErrors: Record<string, string> = {};
if (!formState.title) {

View file

@ -3,6 +3,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react';
import RecommendationReasonForm from './RecommendationReasonForm';
import handleError from '../../../../utils/handleError';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import {Recommendation, useDeleteRecommendation, useEditRecommendation} from '../../../../api/recommendations';
@ -30,6 +31,7 @@ const EditRecommendationModal: React.FC<RoutingModalProps & EditRecommendationMo
modal.remove();
updateRoute('recommendations');
},
onSaveError: handleError,
onValidate: () => {
const newErrors: Record<string, string> = {};
if (!formState.title) {
@ -68,11 +70,12 @@ const EditRecommendationModal: React.FC<RoutingModalProps & EditRecommendationMo
message: 'Successfully deleted the recommendation',
type: 'success'
});
} catch (_) {
} catch (e) {
showToast({
message: 'Failed to delete the recommendation. Please try again later.',
type: 'error'
});
handleError(e, {withToast: false});
}
}
});

View file

@ -6,6 +6,7 @@ import Menu from '../../../../admin-x-ds/global/Menu';
import ModalPage from '../../../../admin-x-ds/global/modal/ModalPage';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import handleError from '../../../../utils/handleError';
import {Theme, isActiveTheme, isDefaultTheme, isDeletableTheme, useActivateTheme, useDeleteTheme} from '../../../../api/themes';
import {downloadFile, getGhostPaths} from '../../../../utils/helpers';
@ -50,7 +51,11 @@ const ThemeActions: React.FC<ThemeActionProps> = ({
const {mutateAsync: deleteTheme} = useDeleteTheme();
const handleActivate = async () => {
await activateTheme(theme.name);
try {
await activateTheme(theme.name);
} catch (e) {
handleError(e);
}
};
const handleDownload = async () => {
@ -80,8 +85,12 @@ const ThemeActions: React.FC<ThemeActionProps> = ({
okRunningLabel: 'Deleting',
okColor: 'red',
onOk: async (modal) => {
await deleteTheme(theme.name);
modal?.remove();
try {
await deleteTheme(theme.name);
modal?.remove();
} catch (e) {
handleError(e);
}
}
});
};

View file

@ -4,6 +4,7 @@ import List from '../../../../admin-x-ds/global/List';
import ListItem from '../../../../admin-x-ds/global/ListItem';
import NiceModal from '@ebay/nice-modal-react';
import React, {ReactNode, useState} from 'react';
import handleError from '../../../../utils/handleError';
import {ConfirmationModalContent} from '../../../../admin-x-ds/global/modal/ConfirmationModal';
import {InstalledTheme, ThemeProblem, useActivateTheme} from '../../../../api/themes';
import {showToast} from '../../../../admin-x-ds/global/Toast';
@ -85,13 +86,17 @@ const ThemeInstalledModal: React.FC<{
title={title}
onOk={async (activateModal) => {
if (!installedTheme.active) {
const resData = await activateTheme(installedTheme.name);
const updatedTheme = resData.themes[0];
try {
const resData = await activateTheme(installedTheme.name);
const updatedTheme = resData.themes[0];
showToast({
type: 'success',
message: <div><span className='capitalize'>{updatedTheme.name}</span> is now your active theme.</div>
});
showToast({
type: 'success',
message: <div><span className='capitalize'>{updatedTheme.name}</span> is now your active theme.</div>
});
} catch (e) {
handleError(e);
}
}
onActivate?.();
activateModal?.remove();

View file

@ -31,9 +31,10 @@ export interface FormHook<State> {
errors: ErrorMessages;
}
const useForm = <State>({initialState, onSave, onValidate}: {
const useForm = <State>({initialState, onSave, onSaveError, onValidate}: {
initialState: State,
onSave: () => void | Promise<void>
onSaveError?: (error: unknown) => void | Promise<void>
onValidate?: () => ErrorMessages
}): FormHook<State> => {
const [formState, setFormState] = useState(initialState);
@ -77,6 +78,7 @@ const useForm = <State>({initialState, onSave, onValidate}: {
setSaveState('saved');
return true;
} catch (e) {
await onSaveError?.(e);
setSaveState('unsaved');
throw e;
}

View file

@ -1,4 +1,5 @@
import React, {useEffect, useRef, useState} from 'react';
import handleError from '../utils/handleError';
import useForm, {ErrorMessages, SaveState} from './useForm';
import useGlobalDirtyState from './useGlobalDirtyState';
import {Setting, SettingValue, useEditSettings} from '../api/settings';
@ -40,6 +41,7 @@ const useSettingGroup = ({onValidate}: {onValidate?: () => ErrorMessages} = {}):
onSave: async () => {
await editSettings?.(changedSettings());
},
onSaveError: handleError,
onValidate
});

View file

@ -1,7 +1,9 @@
import handleError from './handleError';
import handleResponse from './handleResponse';
import {APIError, MaintenanceError, ServerUnreachableError, TimeoutError} from './errors';
import {QueryClient, UseInfiniteQueryOptions, UseQueryOptions, useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {getGhostPaths} from './helpers';
import {useMemo} from 'react';
import {useEffect, useMemo} from 'react';
import {usePage, usePagination} from '../hooks/usePagination';
import {useServices} from '../components/providers/ServiceProvider';
@ -47,27 +49,77 @@ export const useFetchApi = () => {
setTimeout(() => controller.abort(), timeout);
}
try {
const response = await fetch(endpoint, {
headers: {
...defaultHeaders,
...headers
},
method: 'GET',
mode: 'cors',
credentials: 'include',
signal: controller.signal,
...options
});
// attempt retries for 15 seconds in two situations:
// 1. Server Unreachable error from the browser (code 0 or TypeError), typically from short internet blips
// 2. Maintenance error from Ghost, upgrade in progress so API is temporarily unavailable
let attempts = 0;
let retryingMs = 0;
const startTime = Date.now();
const maxRetryingMs = 15_000;
const retryPeriods = [500, 1000];
const retryableErrors = [ServerUnreachableError, MaintenanceError, TypeError];
return handleResponse(response);
} catch (error: any) {
if (error.name === 'AbortError') {
throw new Error('Request timed out');
}
// const getErrorData = (error?: APIError, response?: Response) => {
// const data: Record<string, unknown> = {
// errorName: error?.name,
// attempts,
// totalSeconds: retryingMs / 1000
// };
// if (endpoint.toString().includes('/ghost/api/')) {
// data.server = response?.headers.get('server');
// }
// return data;
// };
throw error;
};
while (true) {
try {
const response = await fetch(endpoint, {
headers: {
...defaultHeaders,
...headers
},
method: 'GET',
mode: 'cors',
credentials: 'include',
signal: controller.signal,
...options
});
// TODO: Add Sentry integration
// if (attempts !== 0 && config.sentry_dsn) {
// captureMessage('Request took multiple attempts', {extra: getErrorData()});
// }
return handleResponse(response);
} catch (error) {
retryingMs = Date.now() - startTime;
if (import.meta.env.MODE !== 'development' && retryableErrors.some(errorClass => error instanceof errorClass) && retryingMs <= maxRetryingMs) {
await new Promise((resolve) => {
setTimeout(resolve, retryPeriods[attempts] || retryPeriods[retryPeriods.length - 1]);
});
attempts += 1;
continue;
}
// TODO: Add Sentry integration
// if (attempts > 0 && config.sentry_dsn) {
// captureMessage('Request failed after multiple attempts', {extra: getErrorData()});
// }
if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') {
throw new TimeoutError();
}
let newError = error;
if (!(error instanceof APIError)) {
newError = new ServerUnreachableError({cause: error});
}
throw newError;
};
}
};
};
@ -91,7 +143,10 @@ interface QueryOptions<ResponseData> {
returnData?: (originalData: unknown) => ResponseData;
}
type QueryHookOptions<ResponseData> = UseQueryOptions<ResponseData> & { searchParams?: Record<string, string> };
type QueryHookOptions<ResponseData> = UseQueryOptions<ResponseData> & {
searchParams?: Record<string, string>;
defaultErrorHandler?: boolean;
};
export const createQuery = <ResponseData>(options: QueryOptions<ResponseData>) => ({searchParams, ...query}: QueryHookOptions<ResponseData> = {}) => {
const url = apiUrl(options.path, searchParams || options.defaultSearchParams);
@ -107,6 +162,12 @@ export const createQuery = <ResponseData>(options: QueryOptions<ResponseData>) =
(result.data && options.returnData) ? options.returnData(result.data) : result.data)
, [result]);
useEffect(() => {
if (result.error && query.defaultErrorHandler !== false) {
handleError(result.error);
}
}, [result.error, query.defaultErrorHandler]);
return {
...result,
data
@ -141,6 +202,12 @@ export const createPaginatedQuery = <ResponseData extends {meta?: Meta}>(options
meta: result.isFetching ? undefined : data?.meta
});
useEffect(() => {
if (result.error && query.defaultErrorHandler !== false) {
handleError(result.error);
}
}, [result.error, query.defaultErrorHandler]);
return {
...result,
data,
@ -154,6 +221,7 @@ type InfiniteQueryOptions<ResponseData> = Omit<QueryOptions<ResponseData>, 'retu
type InfiniteQueryHookOptions<ResponseData> = UseInfiniteQueryOptions<ResponseData> & {
searchParams?: Record<string, string>;
defaultErrorHandler?: boolean;
getNextPageParams: (data: ResponseData, params: Record<string, string>) => Record<string, string>|undefined;
};
@ -169,6 +237,12 @@ export const createInfiniteQuery = <ResponseData>(options: InfiniteQueryOptions<
const data = useMemo(() => result.data && options.returnData(result.data), [result]);
useEffect(() => {
if (result.error && query.defaultErrorHandler !== false) {
handleError(result.error);
}
}, [result.error, query.defaultErrorHandler]);
return {
...result,
data

View file

@ -1,4 +1,6 @@
interface ErrorResponse {
// API errors
export interface ErrorResponse {
errors: Array<{
code: string
context: string | null
@ -14,11 +16,12 @@ interface ErrorResponse {
export class APIError extends Error {
constructor(
public readonly response: Response,
public readonly response?: Response,
public readonly data?: unknown,
message?: string
message?: string,
errorOptions?: ErrorOptions
) {
super(message || response.statusText);
super(message || response?.statusText, errorOptions);
}
}
@ -26,18 +29,77 @@ export class JSONError extends APIError {
constructor(
response: Response,
public readonly data?: ErrorResponse,
message?: string
message?: string,
errorOptions?: ErrorOptions
) {
super(response, data, message);
super(response, data, message, errorOptions);
}
}
export class VersionMismatchError extends JSONError {
constructor(response: Response, data: ErrorResponse, errorOptions?: ErrorOptions) {
super(response, data, 'API server is running a newer version of Ghost, please upgrade.', errorOptions);
}
}
export class ServerUnreachableError extends APIError {
constructor(errorOptions?: ErrorOptions) {
super(undefined, undefined, 'Server was unreachable', errorOptions);
}
}
export class TimeoutError extends APIError {
constructor(errorOptions?: ErrorOptions) {
super(undefined, undefined, 'Request timed out', errorOptions);
}
}
export class RequestEntityTooLargeError extends APIError {
constructor(response: Response, data: unknown, errorOptions?: ErrorOptions) {
super(response, data, 'Request is larger than the maximum file size the server allows', errorOptions);
}
}
export class UnsupportedMediaTypeError extends APIError {
constructor(response: Response, data: unknown, errorOptions?: ErrorOptions) {
super(response, data, 'Request contains an unknown or unsupported file type.', errorOptions);
}
}
export class MaintenanceError extends APIError {
constructor(response: Response, data: unknown, errorOptions?: ErrorOptions) {
super(response, data, 'Ghost is currently undergoing maintenance, please wait a moment then retry.', errorOptions);
}
}
export class ThemeValidationError extends JSONError {
constructor(response: Response, data: ErrorResponse, errorOptions?: ErrorOptions) {
super(response, data, 'Theme is not compatible or contains errors.', errorOptions);
}
}
export class HostLimitError extends JSONError {
constructor(response: Response, data: ErrorResponse, errorOptions?: ErrorOptions) {
super(response, data, 'A hosting plan limit was reached or exceeded.', errorOptions);
}
}
export class EmailError extends JSONError {
constructor(response: Response, data: ErrorResponse, errorOptions?: ErrorOptions) {
super(response, data, 'Please verify your email settings', errorOptions);
}
}
export class ValidationError extends JSONError {
constructor(response: Response, data: ErrorResponse) {
super(response, data, data.errors[0].message);
constructor(response: Response, data: ErrorResponse, errorOptions?: ErrorOptions) {
super(response, data, data.errors[0].message, errorOptions);
}
}
export const errorsWithMessage = [ValidationError, ThemeValidationError, HostLimitError, EmailError];
// Frontend errors
export class AlreadyExistsError extends Error {
constructor(message?: string) {
super(message);

View file

@ -0,0 +1,44 @@
import toast from 'react-hot-toast';
import {APIError, ValidationError} from './errors';
import {showToast} from '../admin-x-ds/global/Toast';
/**
* Generic error handling for API calls. This is enabled by default for queries (can be disabled by
* setting defaultErrorHandler:false when using the query) but should be called when mutations throw
* errors in order to handle anything unexpected.
*
* @param error Thrown error.
* @param options.withToast Show a toast with the error message (default: true).
* In general we should validate on the client side before sending the request to avoid errors,
* so this toast is intended as a worst-case fallback message when we don't know what else to do.
* @returns
*/
const handleError = (error: unknown, {withToast = true}: {withToast?: boolean} = {}) => {
// eslint-disable-next-line no-console
console.error(error);
if (!withToast) {
return;
}
toast.remove();
if (error instanceof ValidationError && error.data?.errors[0]) {
showToast({
message: error.data.errors[0].context || error.data.errors[0].message,
type: 'pageError'
});
} else if (error instanceof APIError) {
showToast({
message: error.message,
type: 'pageError'
});
} else {
showToast({
message: 'Something went wrong, please try again.',
type: 'pageError'
});
}
};
export default handleError;

View file

@ -1,20 +1,34 @@
import {APIError, JSONError, ValidationError} from './errors';
import {APIError, EmailError, ErrorResponse, HostLimitError, JSONError, MaintenanceError, RequestEntityTooLargeError, ServerUnreachableError, ThemeValidationError, UnsupportedMediaTypeError, ValidationError, VersionMismatchError} from './errors';
const handleResponse = async (response: Response) => {
if (response.status === 422) {
const data = await response.json();
if (response.status === 0) {
throw new ServerUnreachableError();
} else if (response.status === 503) {
throw new MaintenanceError(response, await response.text());
} else if (response.status === 415) {
throw new UnsupportedMediaTypeError(response, await response.text());
} else if (response.status === 413) {
throw new RequestEntityTooLargeError(response, await response.text());
} else if (!response.ok) {
if (!response.headers.get('content-type')?.includes('json')) {
throw new APIError(response, await response.text());
}
if (data.errors?.[0]?.type === 'ValidationError') {
const data = await response.json() as ErrorResponse;
if (data.errors?.[0]?.type === 'VersionMismatchError') {
throw new VersionMismatchError(response, data);
} else if (data.errors?.[0]?.type === 'ValidationError') {
throw new ValidationError(response, data);
} else if (data.errors?.[0]?.type === 'ThemeValidationError') {
throw new ThemeValidationError(response, data);
} else if (data.errors?.[0]?.type === 'HostLimitError') {
throw new HostLimitError(response, data);
} else if (data.errors?.[0]?.type === 'EmailError') {
throw new EmailError(response, data);
} else {
throw new JSONError(response, data);
}
} else if (response.status > 299) {
if (response.headers.get('content-type')?.includes('json')) {
throw new JSONError(response, await response.json());
} else {
throw new APIError(response, await response.text());
}
} else if (response.status === 204) {
return;
} else if (response.headers.get('content-type')?.includes('text/csv')) {