Selaa lähdekoodia

switch to relay pagination, improve the infinite scroller

Klaudiusz Dembler 4 vuotta sitten
vanhempi
commit
5e57b848a4

+ 3 - 59
src/api/client.ts

@@ -2,7 +2,7 @@ import { ApolloClient, InMemoryCache } from '@apollo/client'
 import { parseISO } from 'date-fns'
 
 import '@/mocking/server'
-import { offsetLimitPagination } from '@apollo/client/utilities'
+import { relayStylePagination } from '@apollo/client/utilities'
 
 const apolloClient = new ApolloClient({
   uri: '/graphql',
@@ -10,7 +10,8 @@ const apolloClient = new ApolloClient({
     typePolicies: {
       Query: {
         fields: {
-          videos: offsetLimitPagination((args) => {
+          channelsConnection: relayStylePagination(),
+          videosConnection: relayStylePagination((args) => {
             // make sure queries asking for a specific category are separated in cache
             return args?.where?.categoryId_eq
           }),
@@ -39,60 +40,3 @@ const apolloClient = new ApolloClient({
 })
 
 export default apolloClient
-
-// 2020-09-15
-// the following code fragment was written as part of solving apollo client pagination issues
-// it features an example of more advanced cache handling with custom merge/read operations
-// at some point it may prove useful so leaving it here for now
-
-// // based on FieldPolicy from '@apollo/client/cache/inmemory/policies'
-// type TypeableFieldMergeFunction<TExisting = any, TIncoming = TExisting, TArgs = Record<string, unknown>> = (
-//   existing: SafeReadonly<TExisting> | undefined,
-//   incoming: SafeReadonly<TIncoming>,
-//   options: FieldFunctionOptions<TArgs, TArgs>
-// ) => SafeReadonly<TExisting>
-// type TypeableFieldReadFunction<TExisting = any, TReadResult = TExisting, TArgs = Record<string, unknown>> = (
-//   existing: SafeReadonly<TExisting> | undefined,
-//   options: FieldFunctionOptions<TArgs, TArgs>
-// ) => TReadResult | undefined
-//
-// type VideosFieldPolicy = {
-//   merge: TypeableFieldMergeFunction<VideoFields[], VideoFields[], GetVideosVariables>
-//   read?: TypeableFieldReadFunction<VideoFields[], VideoFields[], GetVideosVariables>
-// }
-
-// {
-// merge: (existing, incoming, { variables }) => {
-//   // based on offsetLimitPagination from '@apollo/client/utilities'
-//   const merged = existing ? existing.slice(0) : []
-//   const start = variables?.offset || 0
-//   const end = start + incoming.length
-//   for (let i = start; i < end; ++i) {
-//     merged[i] = incoming[i - start]
-//   }
-//   // console.log({ merged })
-//   return merged
-// },
-// read: (existing, { variables, readField }) => {
-//   // console.log('read')
-//   // console.log({ variables })
-//   if (variables?.categoryId) {
-//     console.log({ existing })
-//     const filtered = existing?.filter((v) => {
-//       let categoryId = v.category?.id
-//       if (!v.category) {
-//         const categoryRef = readField('category', v as any)
-//         categoryId = readField('id', categoryRef as Reference) as string
-//       }
-//       return categoryId === variables.categoryId
-//     })
-//     console.log({ filtered })
-//     if (!filtered?.length) {
-//       return
-//     }
-//     return filtered
-//   }
-//   return existing
-// },
-
-// } as VideosFieldPolicy,

+ 28 - 30
src/api/queries/__generated__/GetFeaturedVideos.ts

@@ -8,53 +8,51 @@
 // ====================================================
 
 export interface GetFeaturedVideos_featured_videos_category {
-  __typename: 'Category'
-  id: string
+  __typename: "Category";
+  id: string;
 }
 
 export interface GetFeaturedVideos_featured_videos_media_location_HTTPVideoMediaLocation {
-  __typename: 'HTTPVideoMediaLocation'
-  URL: string
+  __typename: "HTTPVideoMediaLocation";
+  URL: string;
 }
 
 export interface GetFeaturedVideos_featured_videos_media_location_JoystreamVideoMediaLocation {
-  __typename: 'JoystreamVideoMediaLocation'
-  dataObjectID: string
+  __typename: "JoystreamVideoMediaLocation";
+  dataObjectID: string;
 }
 
-export type GetFeaturedVideos_featured_videos_media_location =
-  | GetFeaturedVideos_featured_videos_media_location_HTTPVideoMediaLocation
-  | GetFeaturedVideos_featured_videos_media_location_JoystreamVideoMediaLocation
+export type GetFeaturedVideos_featured_videos_media_location = GetFeaturedVideos_featured_videos_media_location_HTTPVideoMediaLocation | GetFeaturedVideos_featured_videos_media_location_JoystreamVideoMediaLocation;
 
 export interface GetFeaturedVideos_featured_videos_media {
-  __typename: 'VideoMedia'
-  id: string
-  pixelHeight: number
-  pixelWidth: number
-  location: GetFeaturedVideos_featured_videos_media_location
+  __typename: "VideoMedia";
+  id: string;
+  pixelHeight: number;
+  pixelWidth: number;
+  location: GetFeaturedVideos_featured_videos_media_location;
 }
 
 export interface GetFeaturedVideos_featured_videos_channel {
-  __typename: 'Channel'
-  id: string
-  avatarPhotoURL: string | null
-  handle: string
+  __typename: "Channel";
+  id: string;
+  avatarPhotoURL: string | null;
+  handle: string;
 }
 
 export interface GetFeaturedVideos_featured_videos {
-  __typename: 'Video'
-  id: string
-  title: string
-  description: string
-  category: GetFeaturedVideos_featured_videos_category
-  views: number
-  duration: number
-  thumbnailURL: string
-  publishedOnJoystreamAt: GQLDate
-  media: GetFeaturedVideos_featured_videos_media
-  channel: GetFeaturedVideos_featured_videos_channel
+  __typename: "Video";
+  id: string;
+  title: string;
+  description: string;
+  category: GetFeaturedVideos_featured_videos_category;
+  views: number;
+  duration: number;
+  thumbnailURL: string;
+  publishedOnJoystreamAt: GQLDate;
+  media: GetFeaturedVideos_featured_videos_media;
+  channel: GetFeaturedVideos_featured_videos_channel;
 }
 
 export interface GetFeaturedVideos {
-  featured_videos: GetFeaturedVideos_featured_videos[]
+  featured_videos: GetFeaturedVideos_featured_videos[];
 }

+ 26 - 2
src/api/queries/__generated__/GetNewestChannels.ts

@@ -7,7 +7,7 @@
 // GraphQL query operation: GetNewestChannels
 // ====================================================
 
-export interface GetNewestChannels_channels {
+export interface GetNewestChannels_channelsConnection_edges_node {
   __typename: "Channel";
   id: string;
   handle: string;
@@ -16,6 +16,30 @@ export interface GetNewestChannels_channels {
   totalViews: number;
 }
 
+export interface GetNewestChannels_channelsConnection_edges {
+  __typename: "ChannelEdge";
+  cursor: string;
+  node: GetNewestChannels_channelsConnection_edges_node;
+}
+
+export interface GetNewestChannels_channelsConnection_pageInfo {
+  __typename: "PageInfo";
+  hasNextPage: boolean;
+  endCursor: string | null;
+}
+
+export interface GetNewestChannels_channelsConnection {
+  __typename: "ChannelConnection";
+  edges: GetNewestChannels_channelsConnection_edges[];
+  pageInfo: GetNewestChannels_channelsConnection_pageInfo;
+  totalCount: number;
+}
+
 export interface GetNewestChannels {
-  channels: GetNewestChannels_channels[];
+  channelsConnection: GetNewestChannels_channelsConnection;
+}
+
+export interface GetNewestChannelsVariables {
+  first?: number | null;
+  after?: string | null;
 }

+ 83 - 0
src/api/queries/__generated__/GetNewestVideos.ts

@@ -0,0 +1,83 @@
+/* tslint:disable */
+/* eslint-disable */
+// @generated
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL query operation: GetNewestVideos
+// ====================================================
+
+export interface GetNewestVideos_videosConnection_edges_node_category {
+  __typename: "Category";
+  id: string;
+}
+
+export interface GetNewestVideos_videosConnection_edges_node_media_location_HTTPVideoMediaLocation {
+  __typename: "HTTPVideoMediaLocation";
+  URL: string;
+}
+
+export interface GetNewestVideos_videosConnection_edges_node_media_location_JoystreamVideoMediaLocation {
+  __typename: "JoystreamVideoMediaLocation";
+  dataObjectID: string;
+}
+
+export type GetNewestVideos_videosConnection_edges_node_media_location = GetNewestVideos_videosConnection_edges_node_media_location_HTTPVideoMediaLocation | GetNewestVideos_videosConnection_edges_node_media_location_JoystreamVideoMediaLocation;
+
+export interface GetNewestVideos_videosConnection_edges_node_media {
+  __typename: "VideoMedia";
+  id: string;
+  pixelHeight: number;
+  pixelWidth: number;
+  location: GetNewestVideos_videosConnection_edges_node_media_location;
+}
+
+export interface GetNewestVideos_videosConnection_edges_node_channel {
+  __typename: "Channel";
+  id: string;
+  avatarPhotoURL: string | null;
+  handle: string;
+}
+
+export interface GetNewestVideos_videosConnection_edges_node {
+  __typename: "Video";
+  id: string;
+  title: string;
+  description: string;
+  category: GetNewestVideos_videosConnection_edges_node_category;
+  views: number;
+  duration: number;
+  thumbnailURL: string;
+  publishedOnJoystreamAt: GQLDate;
+  media: GetNewestVideos_videosConnection_edges_node_media;
+  channel: GetNewestVideos_videosConnection_edges_node_channel;
+}
+
+export interface GetNewestVideos_videosConnection_edges {
+  __typename: "VideoEdge";
+  cursor: string;
+  node: GetNewestVideos_videosConnection_edges_node;
+}
+
+export interface GetNewestVideos_videosConnection_pageInfo {
+  __typename: "PageInfo";
+  hasNextPage: boolean;
+  endCursor: string | null;
+}
+
+export interface GetNewestVideos_videosConnection {
+  __typename: "VideoConnection";
+  edges: GetNewestVideos_videosConnection_edges[];
+  pageInfo: GetNewestVideos_videosConnection_pageInfo;
+  totalCount: number;
+}
+
+export interface GetNewestVideos {
+  videosConnection: GetNewestVideos_videosConnection;
+}
+
+export interface GetNewestVideosVariables {
+  first?: number | null;
+  after?: string | null;
+  categoryId?: string | null;
+}

+ 0 - 64
src/api/queries/__generated__/GetVideos.ts

@@ -1,64 +0,0 @@
-/* tslint:disable */
-/* eslint-disable */
-// @generated
-// This file was automatically generated and should not be edited.
-
-// ====================================================
-// GraphQL query operation: GetVideos
-// ====================================================
-
-export interface GetVideos_videos_category {
-  __typename: "Category";
-  id: string;
-}
-
-export interface GetVideos_videos_media_location_HTTPVideoMediaLocation {
-  __typename: "HTTPVideoMediaLocation";
-  URL: string;
-}
-
-export interface GetVideos_videos_media_location_JoystreamVideoMediaLocation {
-  __typename: "JoystreamVideoMediaLocation";
-  dataObjectID: string;
-}
-
-export type GetVideos_videos_media_location = GetVideos_videos_media_location_HTTPVideoMediaLocation | GetVideos_videos_media_location_JoystreamVideoMediaLocation;
-
-export interface GetVideos_videos_media {
-  __typename: "VideoMedia";
-  id: string;
-  pixelHeight: number;
-  pixelWidth: number;
-  location: GetVideos_videos_media_location;
-}
-
-export interface GetVideos_videos_channel {
-  __typename: "Channel";
-  id: string;
-  avatarPhotoURL: string | null;
-  handle: string;
-}
-
-export interface GetVideos_videos {
-  __typename: "Video";
-  id: string;
-  title: string;
-  description: string;
-  category: GetVideos_videos_category;
-  views: number;
-  duration: number;
-  thumbnailURL: string;
-  publishedOnJoystreamAt: GQLDate;
-  media: GetVideos_videos_media;
-  channel: GetVideos_videos_channel;
-}
-
-export interface GetVideos {
-  videos: GetVideos_videos[];
-}
-
-export interface GetVideosVariables {
-  offset?: number | null;
-  limit?: number | null;
-  categoryId?: string | null;
-}

+ 13 - 4
src/api/queries/channels.ts

@@ -11,11 +11,20 @@ export const channelFieldsFragment = gql`
   }
 `
 
-// TODO: Add proper query params (order, limit, etc.)
 export const GET_NEWEST_CHANNELS = gql`
-  query GetNewestChannels {
-    channels {
-      ...ChannelFields
+  query GetNewestChannels($first: Int, $after: String) {
+    channelsConnection(first: $first, after: $after, orderBy: [publishedOnJoystreamAt_DESC]) {
+      edges {
+        cursor
+        node {
+          ...ChannelFields
+        }
+      }
+      pageInfo {
+        hasNextPage
+        endCursor
+      }
+      totalCount
     }
   }
   ${channelFieldsFragment}

+ 19 - 4
src/api/queries/videos.ts

@@ -40,10 +40,25 @@ export const videoFieldsFragment = gql`
   ${videoMediaFieldsFragment}
 `
 
-export const GET_VIDEOS = gql`
-  query GetVideos($offset: Int, $limit: Int, $categoryId: ID) {
-    videos(offset: $offset, limit: $limit, where: { categoryId_eq: $categoryId }) {
-      ...VideoFields
+export const GET_NEWEST_VIDEOS = gql`
+  query GetNewestVideos($first: Int, $after: String, $categoryId: ID) {
+    videosConnection(
+      first: $first
+      after: $after
+      where: { categoryId_eq: $categoryId }
+      orderBy: [publishedOnJoystreamAt_DESC]
+    ) {
+      edges {
+        cursor
+        node {
+          ...VideoFields
+        }
+      }
+      pageInfo {
+        hasNextPage
+        endCursor
+      }
+      totalCount
     }
   }
   ${videoFieldsFragment}

+ 0 - 110
src/mocking/server.ts

@@ -1,110 +0,0 @@
-import { createServer } from 'miragejs'
-import { createGraphQLHandler, mirageGraphQLFieldResolver } from '@miragejs/graphql'
-import { shuffle } from 'lodash'
-import faker from 'faker'
-
-import schema from '../schema.graphql'
-import { FEATURED_VIDEOS_INDEXES, mockCategories, mockChannels, mockVideos, mockVideosMedia } from '@/mocking/data'
-import { SearchVariables } from '@/api/queries/__generated__/Search'
-import { ModelInstance } from 'miragejs/-types'
-import { ChannelFields } from '@/api/queries/__generated__/ChannelFields'
-import { CategoryFields } from '@/api/queries/__generated__/CategoryFields'
-
-createServer({
-  routes() {
-    const graphQLHandler = createGraphQLHandler(schema, this.schema, {
-      resolvers: {
-        Query: {
-          videos: (obj: unknown, { limit, offset, where: { categoryId_eq } }: any, context: unknown, info: unknown) => {
-            const resolverArgs = categoryId_eq ? { categoryId: categoryId_eq } : {}
-            const videos = mirageGraphQLFieldResolver(obj, resolverArgs, context, info)
-
-            if (!limit && !offset) {
-              return videos
-            }
-
-            const start = offset || 0
-            const end = limit ? start + limit : videos.length
-
-            return videos.slice(start, end)
-          },
-          featured_videos: (...params: unknown[]) => {
-            const videos = mirageGraphQLFieldResolver(...params) as unknown[]
-            return videos.filter((_, idx) => FEATURED_VIDEOS_INDEXES.includes(idx))
-          },
-          channels: (...params: unknown[]) => {
-            const channels = mirageGraphQLFieldResolver(...params)
-            return shuffle(channels)
-          },
-          // FIXME: This resolver is currently broken and returns the same result n times instead of the correct result.
-          search: (obj: unknown, { query_string }: SearchVariables, context: unknown, info: unknown) => {
-            const items = [...mockVideos, ...mockChannels]
-
-            let rankCount = 0
-            const matchQueryStr = (str: string) => str.includes(query_string) || query_string.includes(str)
-
-            const relevantItems = items.reduce((acc: any, item) => {
-              const matched =
-                item.__typename === 'Channel'
-                  ? matchQueryStr(item.handle)
-                  : matchQueryStr(item.description) || matchQueryStr(item.title)
-
-              return matched
-                ? [
-                    ...acc,
-                    {
-                      __typename: 'FreeTextSearchResult',
-                      item,
-                      rank: rankCount++,
-                    },
-                  ]
-                : acc
-            }, [])
-            return relevantItems
-          },
-        },
-      },
-    })
-
-    this.post('/graphql', graphQLHandler, { timing: 1500 }) // include load delay
-  },
-
-  seeds(server) {
-    const channels = mockChannels.map((channel) => {
-      return server.schema.create('Channel', {
-        ...channel,
-      }) as ModelInstance<ChannelFields>
-    })
-
-    const categories = mockCategories.map((category) => {
-      return server.schema.create('Category', {
-        ...category,
-      }) as ModelInstance<CategoryFields>
-    })
-
-    const videoMedias = mockVideosMedia.map((videoMedia) => {
-      // FIXME: This suffers from the same behaviour as the search resolver - all the returned items have the same location
-      const location = server.schema.create('HTTPVideoMediaLocation', {
-        id: faker.random.uuid(),
-        ...videoMedia.location,
-      })
-
-      const model = server.schema.create('VideoMedia', {
-        ...videoMedia,
-        location,
-      })
-      return model
-    })
-
-    mockVideos.forEach((video, idx) => {
-      const mediaIndex = idx % mockVideosMedia.length
-      server.schema.create('Video', {
-        ...video,
-        duration: mockVideosMedia[mediaIndex].duration,
-        channel: channels[idx % channels.length],
-        category: categories[idx % categories.length],
-        media: videoMedias[mediaIndex],
-      })
-    })
-  },
-})

+ 45 - 0
src/mocking/server/data.ts

@@ -0,0 +1,45 @@
+import { ModelInstance } from 'miragejs/-types'
+import faker from 'faker'
+
+import { mockCategories, mockChannels, mockVideos, mockVideosMedia } from '@/mocking/data'
+import { ChannelFields } from '@/api/queries/__generated__/ChannelFields'
+import { CategoryFields } from '@/api/queries/__generated__/CategoryFields'
+
+export const createMockData = (server: any) => {
+  const channels = mockChannels.map((channel) => {
+    return server.schema.create('Channel', {
+      ...channel,
+    }) as ModelInstance<ChannelFields>
+  })
+
+  const categories = mockCategories.map((category) => {
+    return server.schema.create('Category', {
+      ...category,
+    }) as ModelInstance<CategoryFields>
+  })
+
+  const videoMedias = mockVideosMedia.map((videoMedia) => {
+    // FIXME: This suffers from the same behaviour as the search resolver - all the returned items have the same location
+    const location = server.schema.create('HTTPVideoMediaLocation', {
+      id: faker.random.uuid(),
+      ...videoMedia.location,
+    })
+
+    const model = server.schema.create('VideoMedia', {
+      ...videoMedia,
+      location,
+    })
+    return model
+  })
+
+  mockVideos.forEach((video, idx) => {
+    const mediaIndex = idx % mockVideosMedia.length
+    server.schema.create('Video', {
+      ...video,
+      duration: mockVideosMedia[mediaIndex].duration,
+      channel: channels[idx % channels.length],
+      category: categories[idx % categories.length],
+      media: videoMedias[mediaIndex],
+    })
+  })
+}

+ 27 - 0
src/mocking/server/index.ts

@@ -0,0 +1,27 @@
+import { createServer } from 'miragejs'
+import { createGraphQLHandler } from '@miragejs/graphql'
+
+import schema from '../../schema.graphql'
+import { createMockData } from './data'
+import { channelsResolver, featuredVideosResolver, searchResolver, videosResolver } from './resolvers'
+
+createServer({
+  routes() {
+    const graphQLHandler = createGraphQLHandler(schema, this.schema, {
+      resolvers: {
+        Query: {
+          videosConnection: videosResolver,
+          featured_videos: featuredVideosResolver,
+          channelsConnection: channelsResolver,
+          search: searchResolver,
+        },
+      },
+    })
+
+    this.post('/graphql', graphQLHandler, { timing: 1500 }) // include load delay
+  },
+
+  seeds(server) {
+    createMockData(server)
+  },
+})

+ 84 - 0
src/mocking/server/resolvers.ts

@@ -0,0 +1,84 @@
+import { mirageGraphQLFieldResolver } from '@miragejs/graphql'
+import { FEATURED_VIDEOS_INDEXES, mockChannels, mockVideos } from '@/mocking/data'
+import { SearchVariables } from '@/api/queries/__generated__/Search'
+
+// eslint-disable-next-line @typescript-eslint/ban-types
+type QueryResolver<T extends object = Record<string, unknown>> = (
+  obj: unknown,
+  args: T,
+  context: unknown,
+  info: unknown
+) => unknown
+
+type VideoQueryArgs = {
+  first: number | null
+  after: string | null
+  where: {
+    categoryId_eq: string | null
+  } | null
+}
+
+export const videosResolver: QueryResolver<VideoQueryArgs> = (obj, args, context, info) => {
+  const baseResolverArgs = {
+    first: args.first,
+    after: args.after,
+  }
+  const extraResolverArgs = args.where?.categoryId_eq
+    ? {
+        categoryId: args.where.categoryId_eq,
+      }
+    : {}
+  const resolverArgs = {
+    ...baseResolverArgs,
+    ...extraResolverArgs,
+  }
+
+  const paginatedVideos = mirageGraphQLFieldResolver(obj, resolverArgs, context, info)
+  const allVideos = mirageGraphQLFieldResolver(obj, extraResolverArgs, context, info)
+
+  return {
+    ...paginatedVideos,
+    totalCount: allVideos.edges.length,
+  }
+}
+
+export const featuredVideosResolver: QueryResolver = (...params) => {
+  const videos = mirageGraphQLFieldResolver(...params) as unknown[]
+  return videos.filter((_, idx) => FEATURED_VIDEOS_INDEXES.includes(idx))
+}
+
+export const channelsResolver: QueryResolver = (obj, args, context, info) => {
+  const paginatedChannels = mirageGraphQLFieldResolver(obj, args, context, info)
+  const allChannels = mirageGraphQLFieldResolver(obj, {}, context, info)
+  return {
+    ...paginatedChannels,
+    totalCount: allChannels.edges.length,
+  }
+}
+
+// FIXME: This resolver is currently broken and returns the same result n times instead of the correct result.
+export const searchResolver: QueryResolver<SearchVariables> = (_, { query_string }) => {
+  const items = [...mockVideos, ...mockChannels]
+
+  let rankCount = 0
+  const matchQueryStr = (str: string) => str.includes(query_string) || query_string.includes(str)
+
+  const relevantItems = items.reduce((acc: any, item) => {
+    const matched =
+      item.__typename === 'Channel'
+        ? matchQueryStr(item.handle)
+        : matchQueryStr(item.description) || matchQueryStr(item.title)
+
+    return matched
+      ? [
+          ...acc,
+          {
+            __typename: 'FreeTextSearchResult',
+            item,
+            rank: rankCount++,
+          },
+        ]
+      : acc
+  }, [])
+  return relevantItems
+}

+ 47 - 2
src/schema.graphql

@@ -153,23 +153,68 @@ type FreeTextSearchResult {
   rank: Int!
 }
 
+type PageInfo {
+  hasNextPage: Boolean!
+  hasPreviousPage: Boolean!
+
+  startCursor: String
+  endCursor: String
+}
+
 input ChannelWhereInput {
   isCurated_eq: Boolean
   isPublic_eq: Boolean
 }
 
+enum ChannelOrderByInput {
+  publishedOnJoystreamAt_ASC
+  publishedOnJoystreamAt_DESC
+}
+
+type ChannelEdge {
+  node: Channel!
+  cursor: String!
+}
+
+type ChannelConnection {
+  edges: [ChannelEdge!]!
+  pageInfo: PageInfo!
+  totalCount: Int!
+}
+
 input VideoWhereInput {
   categoryId_eq: ID
   isCurated_eq: Boolean
   isPublic_eq: Boolean
 }
 
+enum VideoOrderByInput {
+  publishedOnJoystreamAt_ASC
+  publishedOnJoystreamAt_DESC
+}
+
+type VideoEdge {
+  node: Video!
+  cursor: String!
+}
+
+type VideoConnection {
+  edges: [VideoEdge!]!
+  pageInfo: PageInfo!
+  totalCount: Int!
+}
+
 type Query {
   # Lookup a channel by its ID
   channel(id: ID!): Channel
 
   # List all channel by given constraints
-  channels(order_by_creation_date: Boolean, where: ChannelWhereInput, offset: Int, limit: Int): [Channel!]!
+  channelsConnection(
+    first: Int
+    after: String
+    where: ChannelWhereInput
+    orderBy: [ChannelOrderByInput!]
+  ): ChannelConnection!
 
   # Lookup a channel by its ID
   category(id: ID!): Category
@@ -181,7 +226,7 @@ type Query {
   video(id: ID!): Video
 
   # List all videos by given constraints
-  videos(order_by_publication_date: Boolean, where: VideoWhereInput, offset: Int, limit: Int): [Video!]!
+  videosConnection(first: Int, after: String, where: VideoWhereInput, orderBy: [VideoOrderByInput!]): VideoConnection!
 
   # List all top trending videos
   featured_videos: [Video!]!

+ 76 - 30
src/shared/components/InfiniteVideoGrid/InfiniteVideoGrid.tsx

@@ -1,73 +1,119 @@
 import React, { useEffect, useState } from 'react'
 import styled from '@emotion/styled'
-import { VideoFields } from '@/api/queries/__generated__/VideoFields'
 import { spacing, typography } from '../../theme'
 import { VideoPreview, VideoPreviewBase, Grid } from '..'
 import sizes from '@/shared/theme/sizes'
 import { debounce } from 'lodash'
+import { useLazyQuery } from '@apollo/client'
+import { GET_NEWEST_VIDEOS } from '@/api/queries'
+import { GetNewestVideos, GetNewestVideosVariables } from '@/api/queries/__generated__/GetNewestVideos'
 
 type InfiniteVideoGridProps = {
   title?: string
-  videos?: VideoFields[]
-  loadVideos: (offset: number, limit: number) => void
-  initialOffset?: number
-  initialLoading?: boolean
+  categoryId?: string
+  skipCount?: number
+  ready?: boolean
   className?: string
 }
 
-export const INITIAL_ROWS = 4
-export const INITIAL_VIDEOS_PER_ROW = 4
+const INITIAL_ROWS = 4
+const INITIAL_VIDEOS_PER_ROW = 4
 
 const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({
   title,
-  videos,
-  loadVideos,
-  initialOffset = 0,
-  initialLoading,
+  categoryId = '',
+  skipCount = 0,
+  ready = true,
   className,
 }) => {
   const [videosPerRow, setVideosPerRow] = useState(INITIAL_VIDEOS_PER_ROW)
 
-  const loadedVideosCount = videos?.length || 0
-  const videoRowsCount = Math.floor(loadedVideosCount / videosPerRow)
-  const initialRows = Math.max(videoRowsCount, INITIAL_ROWS)
+  const [targetRowsCountByCategory, setTargetRowsCountByCategory] = useState<Record<string, number>>({
+    [categoryId]: INITIAL_ROWS,
+  })
+  const [cachedCategoryId, setCachedCategoryId] = useState<string>(categoryId)
 
-  const [currentRowsCount, setCurrentRowsCount] = useState(initialRows)
+  const targetRowsCount = targetRowsCountByCategory[cachedCategoryId]
 
-  const targetVideosCount = currentRowsCount * videosPerRow
+  const targetDisplayedVideosCount = targetRowsCount * videosPerRow
+  const targetLoadedVideosCount = targetDisplayedVideosCount + skipCount
+
+  const [fetchVideos, { loading, data, fetchMore, called, refetch }] = useLazyQuery<
+    GetNewestVideos,
+    GetNewestVideosVariables
+  >(GET_NEWEST_VIDEOS, {
+    notifyOnNetworkStatusChange: true,
+  })
+
+  const loadedVideosCount = data?.videosConnection.edges.length || 0
+  const allVideosLoaded = data ? !data.videosConnection.pageInfo.hasNextPage : false
+
+  const endCursor = data?.videosConnection.pageInfo.endCursor
 
   useEffect(() => {
-    if (initialLoading) {
+    if (ready && !called) {
+      fetchVideos({ variables: { first: targetLoadedVideosCount, categoryId } })
+    }
+  }, [ready, called, categoryId, targetLoadedVideosCount, fetchVideos])
+
+  useEffect(() => {
+    if (categoryId === cachedCategoryId) {
       return
     }
 
-    if (targetVideosCount > loadedVideosCount) {
-      const offset = initialOffset + loadedVideosCount
-      const missingVideosCount = targetVideosCount - loadedVideosCount
-      loadVideos(offset, missingVideosCount)
-      // TODO: handle a situation when there are no more videos to fetch
-      // this will require query node to provide some pagination metadata (total items count at minimum)
+    setCachedCategoryId(categoryId)
+    const categoryRowsSet = !!targetRowsCountByCategory[categoryId]
+    const categoryRowsCount = categoryRowsSet ? targetRowsCountByCategory[categoryId] : INITIAL_ROWS
+    if (!categoryRowsSet) {
+      setTargetRowsCountByCategory((prevState) => ({
+        ...prevState,
+        [categoryId]: categoryRowsCount,
+      }))
+    }
+
+    if (!called || !refetch) {
+      return
     }
-  }, [initialOffset, initialLoading, loadedVideosCount, targetVideosCount, loadVideos])
 
-  const displayedVideos = videos?.slice(0, videoRowsCount * videosPerRow) || []
-  const placeholderRowsCount = currentRowsCount - videoRowsCount
-  const placeholdersCount = placeholderRowsCount * videosPerRow
+    refetch({ first: categoryRowsCount * videosPerRow + skipCount, categoryId })
+  }, [categoryId, cachedCategoryId, targetRowsCountByCategory, called, refetch, videosPerRow, skipCount])
+
+  useEffect(() => {
+    if (loading || !fetchMore || allVideosLoaded) {
+      return
+    }
+
+    if (targetLoadedVideosCount > loadedVideosCount) {
+      const videosToLoadCount = targetLoadedVideosCount - loadedVideosCount
+      fetchMore({ variables: { first: videosToLoadCount, after: endCursor, categoryId } })
+    }
+  }, [loading, loadedVideosCount, targetLoadedVideosCount, allVideosLoaded, fetchMore, endCursor, categoryId])
 
   useEffect(() => {
     const scrollHandler = debounce(() => {
       const scrolledToBottom =
         window.innerHeight + document.documentElement.scrollTop === document.documentElement.offsetHeight
 
-      if (scrolledToBottom && placeholdersCount === 0) {
-        setCurrentRowsCount(currentRowsCount + 2)
+      if (scrolledToBottom && ready && !loading && !allVideosLoaded) {
+        setTargetRowsCountByCategory((prevState) => ({
+          ...prevState,
+          [cachedCategoryId]: targetRowsCount + 2,
+        }))
       }
     }, 100)
     window.addEventListener('scroll', scrollHandler)
     return () => {
       window.removeEventListener('scroll', scrollHandler)
     }
-  }, [currentRowsCount, placeholdersCount])
+  }, [targetRowsCount, ready, loading, allVideosLoaded, cachedCategoryId])
+
+  const displayedEdges = data?.videosConnection.edges.slice(skipCount, targetLoadedVideosCount) || []
+  const displayedVideos = displayedEdges.map((edge) => edge.node)
+
+  const targetDisplayedItemsCount = data
+    ? Math.min(targetDisplayedVideosCount, data.videosConnection.totalCount - skipCount)
+    : targetDisplayedVideosCount
+  const placeholdersCount = targetDisplayedItemsCount - displayedVideos.length
 
   const gridContent = (
     <>

+ 3 - 2
src/shared/components/InfiniteVideoGrid/index.ts

@@ -1,2 +1,3 @@
-import InfiniteVideoGrid, { INITIAL_ROWS, INITIAL_VIDEOS_PER_ROW } from './InfiniteVideoGrid'
-export { InfiniteVideoGrid as default, INITIAL_ROWS, INITIAL_VIDEOS_PER_ROW }
+import InfiniteVideoGrid from './InfiniteVideoGrid'
+
+export default InfiniteVideoGrid

+ 1 - 1
src/shared/components/index.ts

@@ -21,7 +21,7 @@ export { default as Sidenav, SIDENAV_WIDTH, EXPANDED_SIDENAV_WIDTH, NavItem } fr
 export { default as ChannelAvatar } from './ChannelAvatar'
 export { default as GlobalStyle } from './GlobalStyle'
 export { default as Placeholder } from './Placeholder'
-export { default as InfiniteVideoGrid, INITIAL_ROWS, INITIAL_VIDEOS_PER_ROW } from './InfiniteVideoGrid'
+export { default as InfiniteVideoGrid } from './InfiniteVideoGrid'
 export { default as ToggleButton } from './ToggleButton'
 export { default as Icon } from './Icon'
 export { default as Searchbar } from './Searchbar'

+ 5 - 48
src/views/BrowseView.tsx

@@ -1,61 +1,23 @@
-import React, { useCallback, useState } from 'react'
+import React, { useState } from 'react'
 import styled from '@emotion/styled'
 import { RouteComponentProps } from '@reach/router'
-import {
-  CategoryPicker,
-  InfiniteVideoGrid,
-  INITIAL_ROWS,
-  Typography,
-  INITIAL_VIDEOS_PER_ROW,
-} from '@/shared/components'
+import { CategoryPicker, InfiniteVideoGrid, Typography } from '@/shared/components'
 import { colors, sizes } from '@/shared/theme'
-import { useLazyQuery, useQuery } from '@apollo/client'
-import { GET_CATEGORIES, GET_VIDEOS } from '@/api/queries'
+import { useQuery } from '@apollo/client'
+import { GET_CATEGORIES } from '@/api/queries'
 import { GetCategories } from '@/api/queries/__generated__/GetCategories'
 import { CategoryFields } from '@/api/queries/__generated__/CategoryFields'
-import { GetVideos, GetVideosVariables } from '@/api/queries/__generated__/GetVideos'
 
 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 [
-    loadVideos,
-    { data: videosData, fetchMore: fetchMoreVideos, refetch: refetchVideos, variables: videoQueryVariables },
-  ] = useLazyQuery<GetVideos, GetVideosVariables>(GET_VIDEOS, {
-    notifyOnNetworkStatusChange: true,
-    fetchPolicy: 'cache-and-network',
-  })
 
   const handleCategoryChange = (category: CategoryFields) => {
     setSelectedCategoryId(category.id)
-
-    // TODO: don't require this component to know the initial number of items
-    // I didn't have an idea on how to achieve that for now
-    // it will need to be reworked in some part anyway during switching to relay pagination
-    const variables = { offset: 0, limit: INITIAL_ROWS * INITIAL_VIDEOS_PER_ROW, categoryId: category.id }
-
-    if (!selectedCategoryId) {
-      // first videos fetch
-      loadVideos({ variables })
-    } else if (refetchVideos) {
-      refetchVideos(variables)
-    }
   }
 
-  const handleLoadVideos = useCallback(
-    (offset: number, limit: number) => {
-      if (!selectedCategoryId || !fetchMoreVideos) {
-        return
-      }
-
-      const variables = { offset, limit, categoryId: selectedCategoryId }
-      fetchMoreVideos({ variables })
-    },
-    [selectedCategoryId, fetchMoreVideos]
-  )
-
   return (
     <div>
       <Header variant="hero">Browse</Header>
@@ -66,12 +28,7 @@ const BrowseView: React.FC<RouteComponentProps> = () => {
         selectedCategoryId={selectedCategoryId}
         onChange={handleCategoryChange}
       />
-      <InfiniteVideoGrid
-        key={videoQueryVariables?.categoryId || ''}
-        loadVideos={handleLoadVideos}
-        videos={videosData?.videos}
-        initialLoading={categoriesLoading}
-      />
+      <InfiniteVideoGrid categoryId={selectedCategoryId || undefined} ready={!!selectedCategoryId} />
     </div>
   )
 }

+ 21 - 34
src/views/HomeView.tsx

@@ -1,38 +1,34 @@
-import React, { useCallback } from 'react'
+import React from 'react'
 import styled from '@emotion/styled'
 import { ChannelGallery, Hero, VideoGallery } from '@/components'
 import { RouteComponentProps } from '@reach/router'
 import { useQuery } from '@apollo/client'
-import { GET_FEATURED_VIDEOS, GET_NEWEST_CHANNELS, GET_VIDEOS } from '@/api/queries'
-import { GetFeaturedVideos } from '@/api/queries/__generated__/GetFeaturedVideos'
-import { GetNewestChannels } from '@/api/queries/__generated__/GetNewestChannels'
-import { GetVideos, GetVideosVariables } from '@/api/queries/__generated__/GetVideos'
 import { InfiniteVideoGrid } from '@/shared/components'
+import { GET_FEATURED_VIDEOS, GET_NEWEST_CHANNELS, GET_NEWEST_VIDEOS } from '@/api/queries'
+import { GetFeaturedVideos } from '@/api/queries/__generated__/GetFeaturedVideos'
+import { GetNewestVideos, GetNewestVideosVariables } from '@/api/queries/__generated__/GetNewestVideos'
+import { GetNewestChannels, GetNewestChannelsVariables } from '@/api/queries/__generated__/GetNewestChannels'
 
 const backgroundImg = 'https://eu-central-1.linodeobjects.com/atlas-assets/hero.jpeg'
 
 const NEWEST_VIDEOS_COUNT = 8
+const NEWEST_CHANNELS_COUNT = 8
 
 const HomeView: React.FC<RouteComponentProps> = () => {
-  const { loading: newestVideosLoading, data: videosData, fetchMore: fetchMoreVideos } = useQuery<
-    GetVideos,
-    GetVideosVariables
-  >(GET_VIDEOS, {
-    variables: { limit: 8, offset: 0 },
-  })
+  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>(GET_NEWEST_CHANNELS)
+  const { loading: newestChannelsLoading, data: newestChannelsData } = useQuery<
+    GetNewestChannels,
+    GetNewestChannelsVariables
+  >(GET_NEWEST_CHANNELS, { variables: { first: NEWEST_CHANNELS_COUNT } })
 
-  const newestVideos = videosData?.videos.slice(0, NEWEST_VIDEOS_COUNT)
-  const nextVideos = videosData?.videos.slice(NEWEST_VIDEOS_COUNT)
-
-  const loadVideos = useCallback(
-    (offset: number, limit: number) => {
-      const variables = { offset, limit }
-      fetchMoreVideos({ variables })
-    },
-    [fetchMoreVideos]
-  )
+  const newestVideos = videosData?.videosConnection.edges.slice(0, NEWEST_VIDEOS_COUNT).map((e) => e.node)
+  const newestChannels = newestChannelsData?.channelsConnection.edges.map((e) => e.node)
 
   return (
     <>
@@ -42,19 +38,10 @@ const HomeView: React.FC<RouteComponentProps> = () => {
         <VideoGallery
           title="Featured videos"
           loading={featuredVideosLoading}
-          videos={featuredVideosData?.featured_videos} // eslint-disable-line camelcase
-        />
-        <ChannelGallery
-          title="Newest channels"
-          loading={newestChannelsLoading}
-          channels={newestChannelsData?.channels}
-        />
-        <StyledInfiniteVideoGrid
-          title="More videos"
-          videos={nextVideos}
-          loadVideos={loadVideos}
-          initialOffset={NEWEST_VIDEOS_COUNT}
+          videos={featuredVideosData?.featured_videos}
         />
+        <ChannelGallery title="Newest channels" loading={newestChannelsLoading} channels={newestChannels} />
+        <StyledInfiniteVideoGrid title="More videos" skipCount={NEWEST_VIDEOS_COUNT} />
       </Container>
     </>
   )

+ 13 - 3
tsconfig.json

@@ -2,7 +2,11 @@
   "extends": "./tsconfig.paths.json",
   "compilerOptions": {
     "target": "es2019",
-    "lib": ["dom", "dom.iterable", "es2019"],
+    "lib": [
+      "dom",
+      "dom.iterable",
+      "es2019"
+    ],
     "allowJs": true,
     "skipLibCheck": true,
     "esModuleInterop": true,
@@ -17,6 +21,12 @@
     "strict": true,
     "jsx": "react"
   },
-  "exclude": ["node_modules", "dist", "src/shared/stories"],
-  "include": ["src"]
+  "exclude": [
+    "node_modules",
+    "dist",
+    "src/shared/stories"
+  ],
+  "include": [
+    "src"
+  ]
 }