Browse Source

Add Error Boundaries To Routes and Galleries

Francesco Baccetti 4 years ago
parent
commit
58867e6c2d

+ 1 - 0
package.json

@@ -94,6 +94,7 @@
     "react-docgen-typescript-loader": "^3.7.1",
     "react-dom": "^16.13.1",
     "react-glider": "^2.0.2",
+    "react-error-boundary": "^3.0.2",
     "react-player": "^2.2.0",
     "react-scripts": "3.4.1",
     "react-spring": "^8.0.27",

+ 5 - 2
src/components/ChannelGallery.tsx

@@ -1,5 +1,4 @@
 import React from 'react'
-import styled from '@emotion/styled'
 
 import { ChannelPreviewBase, Gallery } from '@/shared/components'
 import ChannelPreview from './ChannelPreviewWithNavigation'
@@ -10,15 +9,19 @@ type ChannelGalleryProps = {
   title?: string
   channels?: ChannelFields[]
   loading?: boolean
+  error?: Error
 }
 
 const PLACEHOLDERS_COUNT = 12
 
 const trackPadding = `${spacing.xs} 0 0 ${spacing.xs}`
 
-const ChannelGallery: React.FC<ChannelGalleryProps> = ({ title, channels, loading }) => {
+const ChannelGallery: React.FC<ChannelGalleryProps> = ({ title, channels, loading, error }) => {
   const displayPlaceholders = loading || !channels
 
+  if (error) {
+    throw error
+  }
   return (
     <Gallery title={title} trackPadding={trackPadding} itemWidth={210}>
       {displayPlaceholders

+ 30 - 0
src/components/ErrorFallback.tsx

@@ -0,0 +1,30 @@
+import React from 'react'
+import styled from '@emotion/styled'
+import { FallbackProps } from 'react-error-boundary'
+import { Button } from '@/shared/components'
+import { sizes, colors } from '@/shared/theme'
+
+const Container = styled.div`
+  padding: ${sizes.b4};
+  color: ${colors.gray[400]};
+  display: grid;
+  place-items: center;
+`
+
+const StyledButton = styled(Button)`
+  color: ${colors.white};
+`
+
+const ErrorFallback: React.FC<FallbackProps> = ({ error, resetErrorBoundary }) => {
+  return (
+    <Container>
+      <p>Something went wrong:</p>
+      <pre>{error?.message}</pre>
+      <StyledButton variant="tertiary" onClick={resetErrorBoundary}>
+        Try again
+      </StyledButton>
+    </Container>
+  )
+}
+
+export default ErrorFallback

+ 25 - 8
src/components/LayoutWithRouting.tsx

@@ -1,12 +1,29 @@
 import React from 'react'
 import styled from '@emotion/styled'
-import { Router } from '@reach/router'
+import { RouteComponentProps, Router, navigate } from '@reach/router'
+import { ErrorBoundary } from 'react-error-boundary'
 
 import { GlobalStyle } from '@/shared/components'
-import { Navbar } from '@/components'
-import { BrowseView, ChannelView, HomeView, SearchView, VideoView } from '@/views'
+import { Navbar, ErrorFallback } from '@/components'
+import { HomeView, VideoView, SearchView, ChannelView, BrowseView } from '@/views'
 import routes from '@/config/routes'
 
+type RouteProps = {
+  Component: React.ComponentType
+} & RouteComponentProps
+const Route: React.FC<RouteProps> = ({ Component, ...pathProps }) => {
+  return (
+    <ErrorBoundary
+      FallbackComponent={ErrorFallback}
+      onReset={() => {
+        navigate('/')
+      }}
+    >
+      <Component {...pathProps} />
+    </ErrorBoundary>
+  )
+}
+
 const LayoutWithRouting: React.FC = () => (
   <>
     <GlobalStyle />
@@ -15,11 +32,11 @@ const LayoutWithRouting: React.FC = () => (
     </Router>
     <MainContainer>
       <Router primary={false}>
-        <HomeView default />
-        <VideoView path={routes.video()} />
-        <SearchView path={routes.search()} />
-        <BrowseView path={routes.browse()} />
-        <ChannelView path={routes.channel()} />
+        <Route default Component={HomeView} />
+        <Route path={routes.video()} Component={VideoView} />
+        <Route path={routes.search()} Component={SearchView} />
+        <Route Component={BrowseView} path={routes.browse()} />
+        <Route Component={ChannelView} path={routes.channel()} />
       </Router>
     </MainContainer>
   </>

+ 1 - 0
src/components/VideoGallery.tsx

@@ -14,6 +14,7 @@ type VideoGalleryProps = {
   title?: string
   videos?: VideoFields[]
   loading?: boolean
+  error?: Error
 }
 
 const PLACEHOLDERS_COUNT = 12

+ 1 - 0
src/components/index.ts

@@ -8,3 +8,4 @@ export { default as PlaceholderVideoGrid } from './PlaceholderVideoGrid'
 export { default as VideoPreview } from './VideoPreviewWithNavigation'
 export { default as ChannelPreview } from './ChannelPreviewWithNavigation'
 export { default as ChannelGrid } from './ChannelGrid'
+export { default as ErrorFallback } from './ErrorFallback'

+ 10 - 3
src/views/BrowseView.tsx

@@ -10,14 +10,21 @@ import { CategoryFields } from '@/api/queries/__generated__/CategoryFields'
 
 const BrowseView: React.FC<RouteComponentProps> = () => {
   const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
-  const { loading: categoriesLoading, data: categoriesData } = useQuery<GetCategories>(GET_CATEGORIES, {
-    onCompleted: (data) => handleCategoryChange(data.categories[0]),
-  })
+  const { loading: categoriesLoading, data: categoriesData, error: categoriesError } = useQuery<GetCategories>(
+    GET_CATEGORIES,
+    {
+      onCompleted: (data) => handleCategoryChange(data.categories[0]),
+    }
+  )
 
   const handleCategoryChange = (category: CategoryFields) => {
     setSelectedCategoryId(category.id)
   }
 
+  if (categoriesError) {
+    throw categoriesError
+  }
+
   return (
     <div>
       <Header variant="hero">Browse</Header>

+ 8 - 1
src/views/ChannelView/ChannelView.tsx

@@ -21,10 +21,17 @@ const DEFAULT_CHANNEL_COVER_URL = 'https://eu-central-1.linodeobjects.com/atlas-
 
 const ChannelView: React.FC<RouteComponentProps> = () => {
   const { id } = useParams()
-  const { loading, data } = useQuery<GetChannel, GetChannelVariables>(GET_CHANNEL, {
+  const { loading, data, error } = useQuery<GetChannel, GetChannelVariables>(GET_CHANNEL, {
     variables: { id },
   })
 
+  if (error) {
+    throw error
+  }
+
+  if (loading || !data?.channel) {
+    return <p>Loading Channel...</p>
+  }
   const videos = data?.channel?.videos || []
 
   return (

+ 61 - 19
src/views/HomeView.tsx

@@ -1,6 +1,7 @@
 import React from 'react'
 import styled from '@emotion/styled'
-import { ChannelGallery, FeaturedVideoHeader, VideoGallery } from '@/components'
+import { ChannelGallery, FeaturedVideoHeader, ErrorFallback, VideoGallery } from '@/components'
+
 import { RouteComponentProps } from '@reach/router'
 import { useQuery } from '@apollo/client'
 import { InfiniteVideoGrid } from '@/shared/components'
@@ -9,22 +10,34 @@ import { GetFeaturedVideos } from '@/api/queries/__generated__/GetFeaturedVideos
 import { GetNewestVideos, GetNewestVideosVariables } from '@/api/queries/__generated__/GetNewestVideos'
 import { GetNewestChannels, GetNewestChannelsVariables } from '@/api/queries/__generated__/GetNewestChannels'
 import { spacing } from '@/shared/theme'
+import { ErrorBoundary } from 'react-error-boundary'
 
 const NEWEST_VIDEOS_COUNT = 8
 const NEWEST_CHANNELS_COUNT = 8
 
 const HomeView: React.FC<RouteComponentProps> = () => {
-  const { loading: newestVideosLoading, data: videosData } = useQuery<GetNewestVideos, GetNewestVideosVariables>(
-    GET_NEWEST_VIDEOS,
-    {
-      variables: { first: NEWEST_VIDEOS_COUNT },
-    }
-  )
-  const { loading: featuredVideosLoading, data: featuredVideosData } = useQuery<GetFeaturedVideos>(GET_FEATURED_VIDEOS)
-  const { loading: newestChannelsLoading, data: newestChannelsData } = useQuery<
-    GetNewestChannels,
-    GetNewestChannelsVariables
-  >(GET_NEWEST_CHANNELS, { variables: { first: NEWEST_CHANNELS_COUNT } })
+  const {
+    loading: newestVideosLoading,
+    data: videosData,
+    error: newestVideosError,
+    refetch: refetchNewestVideos,
+  } = useQuery<GetNewestVideos, GetNewestVideosVariables>(GET_NEWEST_VIDEOS, {
+    variables: { first: NEWEST_VIDEOS_COUNT },
+  })
+  const {
+    loading: featuredVideosLoading,
+    data: featuredVideosData,
+    error: featuredVideosError,
+    refetch: refetchFeaturedVideos,
+  } = useQuery<GetFeaturedVideos>(GET_FEATURED_VIDEOS)
+  const {
+    loading: newestChannelsLoading,
+    data: newestChannelsData,
+    error: newestChannelsError,
+    refetch: refetchNewestChannels,
+  } = useQuery<GetNewestChannels, GetNewestChannelsVariables>(GET_NEWEST_CHANNELS, {
+    variables: { first: NEWEST_CHANNELS_COUNT },
+  })
 
   const newestVideos = videosData?.videosConnection.edges.slice(0, NEWEST_VIDEOS_COUNT).map((e) => e.node)
   const newestChannels = newestChannelsData?.channelsConnection.edges.map((e) => e.node)
@@ -33,13 +46,42 @@ const HomeView: React.FC<RouteComponentProps> = () => {
     <>
       <FeaturedVideoHeader />
       <Container>
-        <VideoGallery title="Newest videos" loading={newestVideosLoading} videos={newestVideos} />
-        <VideoGallery
-          title="Featured videos"
-          loading={featuredVideosLoading}
-          videos={featuredVideosData?.featured_videos}
-        />
-        <ChannelGallery title="Newest channels" loading={newestChannelsLoading} channels={newestChannels} />
+        <ErrorBoundary
+          FallbackComponent={ErrorFallback}
+          resetKeys={[newestVideos, newestVideosLoading]}
+          onReset={() => refetchNewestVideos()}
+        >
+          <VideoGallery
+            title="Newest videos"
+            loading={newestVideosLoading}
+            videos={newestVideos}
+            error={newestVideosError}
+          />
+        </ErrorBoundary>
+        <ErrorBoundary
+          FallbackComponent={ErrorFallback}
+          resetKeys={[featuredVideosLoading, featuredVideosData]}
+          onReset={() => refetchFeaturedVideos()}
+        >
+          <VideoGallery
+            title="Featured videos"
+            loading={featuredVideosLoading}
+            videos={featuredVideosData?.featured_videos}
+            error={featuredVideosError}
+          />
+        </ErrorBoundary>
+        <ErrorBoundary
+          FallbackComponent={ErrorFallback}
+          resetKeys={[newestChannelsLoading, newestChannels]}
+          onReset={() => refetchNewestChannels()}
+        >
+          <ChannelGallery
+            title="Newest channels"
+            loading={newestChannelsLoading}
+            channels={newestChannels}
+            error={newestChannelsError}
+          />
+        </ErrorBoundary>
         <StyledInfiniteVideoGrid title="More videos" skipCount={NEWEST_VIDEOS_COUNT} />
       </Container>
     </>

+ 7 - 1
src/views/SearchView/SearchView.tsx

@@ -17,7 +17,7 @@ const tabs = ['all results', 'videos', 'channels']
 
 const SearchView: React.FC<SearchViewProps> = ({ search = '' }) => {
   const [selectedIndex, setSelectedIndex] = useState(0)
-  const { data, loading } = useQuery<Search, SearchVariables>(SEARCH, { variables: { query_string: search } })
+  const { data, loading, error } = useQuery<Search, SearchVariables>(SEARCH, { variables: { query_string: search } })
 
   const getChannelsAndVideos = (loading: boolean, data: Search | undefined) => {
     if (loading || !data?.search) {
@@ -31,9 +31,15 @@ const SearchView: React.FC<SearchViewProps> = ({ search = '' }) => {
 
   const { channels, videos } = useMemo(() => getChannelsAndVideos(loading, data), [loading, data])
 
+  if (error) {
+    throw error
+  }
   if (!loading && !data?.search) {
     return <p>Something went wrong...</p>
   }
+  if (loading || !data) {
+    return <p>Loading...</p>
+  }
 
   return (
     <Container>

+ 5 - 1
src/views/VideoView/VideoView.tsx

@@ -26,7 +26,7 @@ import { AddVideoView, AddVideoViewVariables } from '@/api/queries/__generated__
 
 const VideoView: React.FC<RouteComponentProps> = () => {
   const { id } = useParams()
-  const { loading, data } = useQuery<GetVideo, GetVideoVariables>(GET_VIDEO_WITH_CHANNEL_VIDEOS, {
+  const { loading, data, error } = useQuery<GetVideo, GetVideoVariables>(GET_VIDEO_WITH_CHANNEL_VIDEOS, {
     variables: { id },
   })
   const [addVideoView] = useMutation<AddVideoView, AddVideoViewVariables>(ADD_VIDEO_VIEW)
@@ -55,6 +55,10 @@ const VideoView: React.FC<RouteComponentProps> = () => {
     })
   }, [addVideoView, videoID])
 
+  if(error) {
+    throw error
+  }
+
   if (!loading && !data?.video) {
     return <p>Video not found</p>
   }

+ 7 - 0
yarn.lock

@@ -14046,6 +14046,13 @@ react-element-to-jsx-string@^14.1.0, react-element-to-jsx-string@^14.3.1:
     "@base2/pretty-print-object" "1.0.0"
     is-plain-object "3.0.0"
 
+react-error-boundary@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.0.2.tgz#74399a4d9a68bfede1f3f4261ea0aabfc65d9868"
+  integrity sha512-KVzCusRTFpUYG0OFJbzbdRuxNQOBiGXVCqyNpBXM9z5NFsFLzMjUXMjx8gTja6M6WH+D2PvP3yKz4d8gD1PRaA==
+  dependencies:
+    "@babel/runtime" "^7.11.2"
+
 react-error-overlay@^6.0.3, react-error-overlay@^6.0.7:
   version "6.0.7"
   resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108"