Added error boundaries to AdminX to avoid crashing the entire page (#18255)

refs https://github.com/TryGhost/Product/issues/3832
This commit is contained in:
Jono M 2023-09-21 10:42:48 +01:00 committed by GitHub
parent ffafec9690
commit c572f2855d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 127 additions and 29 deletions

View File

@ -0,0 +1,22 @@
import ErrorBoundary from './ErrorBoundary';
import type {Meta, StoryObj} from '@storybook/react';
const meta = {
title: 'Global / Error Boundary',
component: ErrorBoundary,
tags: ['autodocs']
} satisfies Meta<typeof ErrorBoundary>;
export default meta;
type Story = StoryObj<typeof ErrorBoundary>;
const RaisesError = () => {
throw new Error('Something went wrong');
};
export const WithError: Story = {
args: {
name: 'Test Section',
children: <RaisesError />
}
};

View File

@ -0,0 +1,50 @@
import Banner from './Banner';
import React, {ComponentType, ErrorInfo, ReactNode} from 'react';
/**
* Catches errors in child components and displays a banner. Useful to prevent errors in one
* section from crashing the entire page
*/
class ErrorBoundary extends React.Component<{children: ReactNode, name: ReactNode}> {
state = {hasError: false};
constructor(props: {children: ReactNode, name: ReactNode}) {
super(props);
}
static getDerivedStateFromError() {
return {hasError: true};
}
componentDidCatch(error: unknown, info: ErrorInfo) {
// TODO: Log to Sentry
// eslint-disable-next-line no-console
console.error(error);
// eslint-disable-next-line no-console
console.error('In component:', info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<Banner color='red'>
An error occurred loading {this.props.name}. Please refresh and try again.
</Banner>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
export const withErrorBoundary = <Props extends Record<string, unknown>>(Component: ComponentType<Props>, name: string) => {
return function WithErrorBoundary(props: Props) {
return (
<ErrorBoundary name={name}>
<Component {...props} />
</ErrorBoundary>
);
};
};

View File

@ -8,6 +8,7 @@ import TabView from '../../../admin-x-ds/global/TabView';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {ReactCodeMirrorRef} from '@uiw/react-codemirror';
import {getSettingValues} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
@ -93,4 +94,4 @@ const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default CodeInjection;
export default withErrorBoundary(CodeInjection, 'Code injection');

View File

@ -2,6 +2,7 @@ import Button from '../../../admin-x-ds/global/Button';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import useRouting from '../../../hooks/useRouting';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const History: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();
@ -21,4 +22,4 @@ const History: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default History;
export default withErrorBoundary(History, 'History');

View File

@ -19,6 +19,7 @@ import {ReactComponent as ZapierIcon} from '../../../assets/icons/zapier.svg';
import {getSettingValues} from '../../../api/settings';
import {showToast} from '../../../admin-x-ds/global/Toast';
import {useGlobalData} from '../../providers/GlobalDataProvider';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
interface IntegrationItemProps {
icon?: React.ReactNode,
@ -236,4 +237,4 @@ const Integrations: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default Integrations;
export default withErrorBoundary(Integrations, 'Integrations');

View File

@ -8,6 +8,7 @@ import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupHeader from '../../../admin-x-ds/settings/SettingGroupHeader';
import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
import {useGlobalData} from '../../providers/GlobalDataProvider';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
type LabsTab = 'labs-migration-options' | 'labs-alpha-features' | 'labs-beta-features';
@ -66,4 +67,4 @@ const Labs: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default Labs;
export default withErrorBoundary(Labs, 'Labs');

View File

@ -10,6 +10,7 @@ import {getSettingValues} from '../../../api/settings';
import {useBrowseLabels} from '../../../api/labels';
import {useBrowseOffers} from '../../../api/offers';
import {useBrowseTiers} from '../../../api/tiers';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
type RefipientValueArgs = {
defaultEmailRecipients: string;
@ -206,4 +207,4 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default DefaultRecipients;
export default withErrorBoundary(DefaultRecipients, 'Default recipients');

View File

@ -7,6 +7,7 @@ import Toggle from '../../../admin-x-ds/global/form/Toggle';
import useRouting from '../../../hooks/useRouting';
import {Setting, getSettingValues, useEditSettings} from '../../../api/settings';
import {useGlobalData} from '../../providers/GlobalDataProvider';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const EnableNewsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {settings} = useGlobalData();
@ -75,4 +76,4 @@ const EnableNewsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
</SettingGroup>);
};
export default EnableNewsletters;
export default withErrorBoundary(EnableNewsletters, 'Newsletter sending');

View File

@ -7,6 +7,7 @@ import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupConten
import TextField from '../../../admin-x-ds/global/form/TextField';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {getSettingValues, useEditSettings} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const MAILGUN_REGIONS = [
{label: '🇺🇸 US', value: 'https://api.mailgun.net/v3'},
@ -122,4 +123,4 @@ const MailGun: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default MailGun;
export default withErrorBoundary(MailGun, 'Mailgun');

View File

@ -5,6 +5,7 @@ import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import useRouting from '../../../hooks/useRouting';
import {useBrowseNewsletters} from '../../../api/newsletters';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();
@ -46,4 +47,4 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default Newsletters;
export default withErrorBoundary(Newsletters, 'Newsletters');

View File

@ -8,6 +8,7 @@ import useSettingGroup from '../../../hooks/useSettingGroup';
import {ReactComponent as FacebookLogo} from '../../../admin-x-ds/assets/images/facebook-logo.svg';
import {getImageUrl, useUploadImage} from '../../../api/images';
import {getSettingValues} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
@ -141,4 +142,4 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default Facebook;
export default withErrorBoundary(Facebook, 'Facebook card');

View File

@ -7,6 +7,7 @@ import TextField from '../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../admin-x-ds/global/form/Toggle';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {getSettingValues} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const LockSite: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
@ -108,4 +109,4 @@ const LockSite: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default LockSite;
export default withErrorBoundary(LockSite, 'Make site private');

View File

@ -7,6 +7,7 @@ import useSettingGroup from '../../../hooks/useSettingGroup';
import {ReactComponent as GoogleLogo} from '../../../admin-x-ds/assets/images/google-logo.svg';
import {ReactComponent as MagnifyingGlass} from '../../../admin-x-ds/assets/icons/magnifying-glass.svg';
import {getSettingValues} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
interface SearchEnginePreviewProps {
title: string;
@ -126,4 +127,4 @@ const Metadata: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default Metadata;
export default withErrorBoundary(Metadata, 'Meta data');

View File

@ -4,6 +4,7 @@ import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupConten
import TextField from '../../../admin-x-ds/global/form/TextField';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {getSettingValues} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const PublicationLanguage: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
@ -71,4 +72,4 @@ const PublicationLanguage: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default PublicationLanguage;
export default withErrorBoundary(PublicationLanguage, 'Publication language');

View File

@ -5,6 +5,7 @@ import TextField from '../../../admin-x-ds/global/form/TextField';
import useSettingGroup from '../../../hooks/useSettingGroup';
import validator from 'validator';
import {getSettingValues} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
function validateFacebookUrl(newUrl: string) {
const errMessage = 'The URL must be in a format like https://www.facebook.com/yourPage';
@ -87,10 +88,10 @@ const SocialAccounts: React.FC<{ keywords: string[] }> = ({keywords}) => {
const twitterInputRef = useRef<HTMLInputElement>(null);
const [facebookHandle, twitterHandle] = getSettingValues(localSettings, ['facebook', 'twitter']) as string[];
const [facebookHandle, twitterHandle] = getSettingValues<string | null>(localSettings, ['facebook', 'twitter']);
const [facebookUrl, setFacebookUrl] = useState(facebookHandleToUrl(facebookHandle));
const [twitterUrl, setTwitterUrl] = useState(twitterHandleToUrl(twitterHandle));
const [facebookUrl, setFacebookUrl] = useState(facebookHandle ? facebookHandleToUrl(facebookHandle) : '');
const [twitterUrl, setTwitterUrl] = useState(twitterHandle ? twitterHandleToUrl(twitterHandle) : '');
const values = (
<SettingGroupContent
@ -199,4 +200,4 @@ const SocialAccounts: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default SocialAccounts;
export default withErrorBoundary(SocialAccounts, 'Social accounts');

View File

@ -6,6 +6,7 @@ import timezoneData from '@tryghost/timezone-data';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {getLocalTime} from '../../../utils/helpers';
import {getSettingValues} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
interface TimezoneDataDropdownOption {
name: string;
@ -100,4 +101,4 @@ const TimeZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default TimeZone;
export default withErrorBoundary(TimeZone, 'Site timezone');

View File

@ -4,6 +4,7 @@ import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupConten
import TextField from '../../../admin-x-ds/global/form/TextField';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {getSettingValues} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const TitleAndDescription: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
@ -82,4 +83,4 @@ const TitleAndDescription: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default TitleAndDescription;
export default withErrorBoundary(TitleAndDescription, 'Title & description');

View File

@ -8,6 +8,7 @@ import useSettingGroup from '../../../hooks/useSettingGroup';
import {ReactComponent as TwitterLogo} from '../../../admin-x-ds/assets/images/twitter-logo.svg';
import {getImageUrl, useUploadImage} from '../../../api/images';
import {getSettingValues} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
@ -141,4 +142,4 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default Twitter;
export default withErrorBoundary(Twitter, 'Twitter card');

View File

@ -14,6 +14,7 @@ import {UserInvite, useAddInvite, useDeleteInvite} from '../../../api/invites';
import {generateAvatarColor, getInitials} from '../../../utils/helpers';
import {showToast} from '../../../admin-x-ds/global/Toast';
import {useGlobalData} from '../../providers/GlobalDataProvider';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
interface OwnerProps {
user: User;
@ -258,4 +259,4 @@ const Users: React.FC<{ keywords: string[], highlight?: boolean }> = ({keywords,
);
};
export default Users;
export default withErrorBoundary(Users, 'Staff');

View File

@ -8,6 +8,7 @@ import {GroupBase, MultiValue} from 'react-select';
import {getOptionLabel} from '../../../utils/helpers';
import {getSettingValues} from '../../../api/settings';
import {useBrowseTiers} from '../../../api/tiers';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const MEMBERS_SIGNUP_ACCESS_OPTIONS = [
{
@ -190,4 +191,4 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default Access;
export default withErrorBoundary(Access, 'Access');

View File

@ -6,6 +6,7 @@ import Toggle from '../../../admin-x-ds/global/form/Toggle';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {getSettingValues} from '../../../api/settings';
import {usePostsExports} from '../../../api/posts';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const Analytics: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
@ -105,4 +106,4 @@ const Analytics: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default Analytics;
export default withErrorBoundary(Analytics, 'Analytics');

View File

@ -2,6 +2,7 @@ import Button from '../../../admin-x-ds/global/Button';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import useRouting from '../../../hooks/useRouting';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const Portal: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();
@ -22,4 +23,4 @@ const Portal: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default Portal;
export default withErrorBoundary(Portal, 'Portal settings');

View File

@ -8,6 +8,7 @@ import useRouting from '../../../hooks/useRouting';
import {Tier, getActiveTiers, getArchivedTiers, useBrowseTiers} from '../../../api/tiers';
import {checkStripeEnabled} from '../../../api/settings';
import {useGlobalData} from '../../providers/GlobalDataProvider';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const StripeConnectedButton: React.FC<{className?: string; onClick: () => void;}> = ({className, onClick}) => {
className = clsx(
@ -83,4 +84,4 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default Tiers;
export default withErrorBoundary(Tiers, 'Tiers');

View File

@ -9,6 +9,7 @@ import useSettingGroup from '../../../hooks/useSettingGroup';
import {confirmIfDirty} from '../../../utils/modals';
import {currencySelectGroups, getSymbol, validateCurrencyAmount} from '../../../utils/currency';
import {getSettingValues} from '../../../api/settings';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const TipsOrDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
@ -132,4 +133,4 @@ const TipsOrDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default TipsOrDonations;
export default withErrorBoundary(TipsOrDonations, 'Tips or donations');

View File

@ -2,6 +2,7 @@ import Button from '../../../admin-x-ds/global/Button';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import useRouting from '../../../hooks/useRouting';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const AnnouncementBar: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();
@ -21,4 +22,4 @@ const AnnouncementBar: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default AnnouncementBar;
export default withErrorBoundary(AnnouncementBar, 'Announcement bar');

View File

@ -2,6 +2,7 @@ import Button from '../../../admin-x-ds/global/Button';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import useRouting from '../../../hooks/useRouting';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const DesignSetting: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();
@ -21,4 +22,4 @@ const DesignSetting: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default DesignSetting;
export default withErrorBoundary(DesignSetting, 'Branding and design');

View File

@ -2,6 +2,7 @@ import Button from '../../../admin-x-ds/global/Button';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import useRouting from '../../../hooks/useRouting';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const Navigation: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();
@ -21,4 +22,4 @@ const Navigation: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default Navigation;
export default withErrorBoundary(Navigation, 'Navigation');

View File

@ -8,6 +8,7 @@ import TabView from '../../../admin-x-ds/global/TabView';
import useRouting from '../../../hooks/useRouting';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {useBrowseRecommendations} from '../../../api/recommendations';
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
@ -74,4 +75,4 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
);
};
export default Recommendations;
export default withErrorBoundary(Recommendations, 'Recommendations');