diff --git a/apps/admin-x-settings/src/components/settings/advanced/Integrations.tsx b/apps/admin-x-settings/src/components/settings/advanced/Integrations.tsx index 234612bd13..0611c5b3b8 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/Integrations.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/Integrations.tsx @@ -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); + } } }); }} diff --git a/apps/admin-x-settings/src/components/settings/advanced/integrations/AddIntegrationModal.tsx b/apps/admin-x-settings/src/components/settings/advanced/integrations/AddIntegrationModal.tsx index 440caaf15d..6b46a3ddd1 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/integrations/AddIntegrationModal.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/integrations/AddIntegrationModal.tsx @@ -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 = () => { 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); + } }} >
diff --git a/apps/admin-x-settings/src/components/settings/advanced/integrations/AmpModal.tsx b/apps/admin-x-settings/src/components/settings/advanced/integrations/AmpModal.tsx index 1cc063160b..139a9d1e5b 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/integrations/AmpModal.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/integrations/AmpModal.tsx @@ -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; \ No newline at end of file +export default AmpModal; diff --git a/apps/admin-x-settings/src/components/settings/advanced/integrations/CustomIntegrationModal.tsx b/apps/admin-x-settings/src/components/settings/advanced/integrations/CustomIntegrationModal.tsx index 26186423d3..f42250763f 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/integrations/CustomIntegrationModal.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/integrations/CustomIntegrationModal.tsx @@ -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 = {}; @@ -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 diff --git a/apps/admin-x-settings/src/components/settings/advanced/integrations/FirstPromoterModal.tsx b/apps/admin-x-settings/src/components/settings/advanced/integrations/FirstPromoterModal.tsx index 8f9baaba9a..721b3316c3 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/integrations/FirstPromoterModal.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/integrations/FirstPromoterModal.tsx @@ -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); + } }} > { }); } catch (e) { setUploadingState({js: false, css: false}); - showToast({ - type: 'pageError', - message: `Can't upload Pintura ${form}!` - }); + handleError(e); } }; diff --git a/apps/admin-x-settings/src/components/settings/advanced/integrations/UnsplashModal.tsx b/apps/admin-x-settings/src/components/settings/advanced/integrations/UnsplashModal.tsx index 3194fe97af..eb8d87bd68 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/integrations/UnsplashModal.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/integrations/UnsplashModal.tsx @@ -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 ( diff --git a/apps/admin-x-settings/src/components/settings/advanced/integrations/WebhookModal.tsx b/apps/admin-x-settings/src/components/settings/advanced/integrations/WebhookModal.tsx index 7e192a9b5f..dcd62c8a4b 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/integrations/WebhookModal.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/integrations/WebhookModal.tsx @@ -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 = ({webhook, integrationId}) => await createWebhook({...formState, integration_id: integrationId}); } }, + onSaveError: handleError, onValidate: () => { const newErrors: Record = {}; diff --git a/apps/admin-x-settings/src/components/settings/advanced/integrations/WebhooksTable.tsx b/apps/admin-x-settings/src/components/settings/advanced/integrations/WebhooksTable.tsx index 976aab1d64..4c379cda01 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/integrations/WebhooksTable.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/integrations/WebhooksTable.tsx @@ -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); + } } }); }; diff --git a/apps/admin-x-settings/src/components/settings/advanced/integrations/ZapierModal.tsx b/apps/admin-x-settings/src/components/settings/advanced/integrations/ZapierModal.tsx index 080b4b2389..3deaf8ed43 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/integrations/ZapierModal.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/integrations/ZapierModal.tsx @@ -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); + } } }); }; diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/BetaFeatures.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/BetaFeatures.tsx index 6fd2e4c374..0de3eba5c4 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/BetaFeatures.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/BetaFeatures.tsx @@ -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 = () => { { - 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); + } }} >
diff --git a/apps/admin-x-settings/src/components/settings/general/users/ChangePasswordForm.tsx b/apps/admin-x-settings/src/components/settings/general/users/ChangePasswordForm.tsx index 7cce0b8731..847e1911b2 100644 --- a/apps/admin-x-settings/src/components/settings/general/users/ChangePasswordForm.tsx +++ b/apps/admin-x-settings/src/components/settings/general/users/ChangePasswordForm.tsx @@ -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}); } }} /> diff --git a/apps/admin-x-settings/src/components/settings/membership/portal/LookAndFeel.tsx b/apps/admin-x-settings/src/components/settings/membership/portal/LookAndFeel.tsx index 3f573135e8..7291b924f2 100644 --- a/apps/admin-x-settings/src/components/settings/membership/portal/LookAndFeel.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/portal/LookAndFeel.tsx @@ -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 = () => { diff --git a/apps/admin-x-settings/src/components/settings/membership/portal/PortalModal.tsx b/apps/admin-x-settings/src/components/settings/membership/portal/PortalModal.tsx index b34c397735..34b67694d5 100644 --- a/apps/admin-x-settings/src/components/settings/membership/portal/PortalModal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/portal/PortalModal.tsx @@ -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>({}); diff --git a/apps/admin-x-settings/src/components/settings/membership/stripe/StripeConnectModal.tsx b/apps/admin-x-settings/src/components/settings/membership/stripe/StripeConnectModal.tsx index ab645c4453..055e2d0640 100644 --- a/apps/admin-x-settings/src/components/settings/membership/stripe/StripeConnectModal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/stripe/StripeConnectModal.tsx @@ -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); + } } }); }; diff --git a/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx b/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx index d49ef19d7e..ad66e9671d 100644 --- a/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx @@ -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 = { diff --git a/apps/admin-x-settings/src/components/settings/site/DesignModal.tsx b/apps/admin-x-settings/src/components/settings/site/DesignModal.tsx index 8ee97870e8..493613eec3 100644 --- a/apps/admin-x-settings/src/components/settings/site/DesignModal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/DesignModal.tsx @@ -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(() => { diff --git a/apps/admin-x-settings/src/components/settings/site/ThemeModal.tsx b/apps/admin-x-settings/src/components/settings/site/ThemeModal.tsx index 381ce77e65..75a12a8324 100644 --- a/apps/admin-x-settings/src/components/settings/site/ThemeModal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/ThemeModal.tsx @@ -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 = ({ 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, {selectedTheme.name} 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]; diff --git a/apps/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx b/apps/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx index 23d96c22eb..6e260a1ce8 100644 --- a/apps/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx +++ b/apps/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx @@ -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 diff --git a/apps/admin-x-settings/src/components/settings/site/designAndBranding/ThemeSettings.tsx b/apps/admin-x-settings/src/components/settings/site/designAndBranding/ThemeSettings.tsx index 7ae511c303..3824d28c42 100644 --- a/apps/admin-x-settings/src/components/settings/site/designAndBranding/ThemeSettings.tsx +++ b/apps/admin-x-settings/src/components/settings/site/designAndBranding/ThemeSettings.tsx @@ -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) { diff --git a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx index 62dc4bfd90..721c12bca1 100644 --- a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx +++ b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx @@ -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 = ({r }); updateRoute('recommendations'); }, + onSaveError: handleError, onValidate: () => { const newErrors: Record = {}; if (!formState.title) { diff --git a/apps/admin-x-settings/src/components/settings/site/recommendations/EditRecommendationModal.tsx b/apps/admin-x-settings/src/components/settings/site/recommendations/EditRecommendationModal.tsx index 95cbc0816a..407707acaf 100644 --- a/apps/admin-x-settings/src/components/settings/site/recommendations/EditRecommendationModal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/recommendations/EditRecommendationModal.tsx @@ -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 { const newErrors: Record = {}; if (!formState.title) { @@ -68,11 +70,12 @@ const EditRecommendationModal: React.FC = ({ 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 = ({ okRunningLabel: 'Deleting', okColor: 'red', onOk: async (modal) => { - await deleteTheme(theme.name); - modal?.remove(); + try { + await deleteTheme(theme.name); + modal?.remove(); + } catch (e) { + handleError(e); + } } }); }; diff --git a/apps/admin-x-settings/src/components/settings/site/theme/ThemeInstalledModal.tsx b/apps/admin-x-settings/src/components/settings/site/theme/ThemeInstalledModal.tsx index 61ae9b19fe..36348a6736 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/ThemeInstalledModal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/ThemeInstalledModal.tsx @@ -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:
{updatedTheme.name} is now your active theme.
- }); + showToast({ + type: 'success', + message:
{updatedTheme.name} is now your active theme.
+ }); + } catch (e) { + handleError(e); + } } onActivate?.(); activateModal?.remove(); diff --git a/apps/admin-x-settings/src/hooks/useForm.ts b/apps/admin-x-settings/src/hooks/useForm.ts index 8df9620052..2bc0c8be3f 100644 --- a/apps/admin-x-settings/src/hooks/useForm.ts +++ b/apps/admin-x-settings/src/hooks/useForm.ts @@ -31,9 +31,10 @@ export interface FormHook { errors: ErrorMessages; } -const useForm = ({initialState, onSave, onValidate}: { +const useForm = ({initialState, onSave, onSaveError, onValidate}: { initialState: State, onSave: () => void | Promise + onSaveError?: (error: unknown) => void | Promise onValidate?: () => ErrorMessages }): FormHook => { const [formState, setFormState] = useState(initialState); @@ -77,6 +78,7 @@ const useForm = ({initialState, onSave, onValidate}: { setSaveState('saved'); return true; } catch (e) { + await onSaveError?.(e); setSaveState('unsaved'); throw e; } diff --git a/apps/admin-x-settings/src/hooks/useSettingGroup.tsx b/apps/admin-x-settings/src/hooks/useSettingGroup.tsx index a95ee31622..6c34d52cd3 100644 --- a/apps/admin-x-settings/src/hooks/useSettingGroup.tsx +++ b/apps/admin-x-settings/src/hooks/useSettingGroup.tsx @@ -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 }); diff --git a/apps/admin-x-settings/src/utils/apiRequests.ts b/apps/admin-x-settings/src/utils/apiRequests.ts index 8e31e86ee6..ac8bd51f72 100644 --- a/apps/admin-x-settings/src/utils/apiRequests.ts +++ b/apps/admin-x-settings/src/utils/apiRequests.ts @@ -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 = { + // 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 { returnData?: (originalData: unknown) => ResponseData; } -type QueryHookOptions = UseQueryOptions & { searchParams?: Record }; +type QueryHookOptions = UseQueryOptions & { + searchParams?: Record; + defaultErrorHandler?: boolean; +}; export const createQuery = (options: QueryOptions) => ({searchParams, ...query}: QueryHookOptions = {}) => { const url = apiUrl(options.path, searchParams || options.defaultSearchParams); @@ -107,6 +162,12 @@ export const createQuery = (options: QueryOptions) = (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 = (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 = Omit, 'retu type InfiniteQueryHookOptions = UseInfiniteQueryOptions & { searchParams?: Record; + defaultErrorHandler?: boolean; getNextPageParams: (data: ResponseData, params: Record) => Record|undefined; }; @@ -169,6 +237,12 @@ export const createInfiniteQuery = (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 diff --git a/apps/admin-x-settings/src/utils/errors.ts b/apps/admin-x-settings/src/utils/errors.ts index 4215ebd4f2..723a20d4ef 100644 --- a/apps/admin-x-settings/src/utils/errors.ts +++ b/apps/admin-x-settings/src/utils/errors.ts @@ -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); diff --git a/apps/admin-x-settings/src/utils/handleError.ts b/apps/admin-x-settings/src/utils/handleError.ts new file mode 100644 index 0000000000..bbe0056022 --- /dev/null +++ b/apps/admin-x-settings/src/utils/handleError.ts @@ -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; diff --git a/apps/admin-x-settings/src/utils/handleResponse.ts b/apps/admin-x-settings/src/utils/handleResponse.ts index 1318fc38c8..ffc8f12be0 100644 --- a/apps/admin-x-settings/src/utils/handleResponse.ts +++ b/apps/admin-x-settings/src/utils/handleResponse.ts @@ -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')) {