Explorar o código

add channel follows count (#212)

* update graphql schema and mocking function

* generate new data and queries

* update ChannelView styles

* propagate latest orion changes

* create resolver, transform for channel follows

* create a resolver for channelFollows query in orionHandler

* generate updated data and types

* refactor resolvers

* update placeholders in ChannelView

* update addVideoResolver

Co-authored-by: Klaudiusz Dembler <dev@kdembler.com>
Bartosz Dryl %!s(int64=4) %!d(string=hai) anos
pai
achega
00b75b03ff

+ 1 - 0
scripts/mocking/generateChannels.js

@@ -11,6 +11,7 @@ const generateChannel = () => {
   return {
     id: faker.random.uuid(),
     handle: faker.lorem.words(handleWordsCount),
+    follows: faker.random.number(150000),
   }
 }
 

+ 44 - 9
src/api/client/resolvers.ts

@@ -1,11 +1,19 @@
 import { GraphQLSchema } from 'graphql'
-import { delegateToSchema } from '@graphql-tools/delegate'
+import { delegateToSchema, Transform } from '@graphql-tools/delegate'
 import type { IResolvers, ISchemaLevelResolver } from '@graphql-tools/utils'
-import { TransformOrionViewsField, ORION_VIEWS_QUERY_NAME, RemoveQueryNodeViewsField } from './transforms'
+import {
+  TransformOrionViewsField,
+  ORION_VIEWS_QUERY_NAME,
+  RemoveQueryNodeViewsField,
+  RemoveQueryNodeFollowsField,
+  ORION_FOLLOWS_QUERY_NAME,
+  TransformOrionFollowsField,
+} from './transforms'
 
-const createResolverWithoutVideoViewsField = (
+const createResolverWithTransforms = (
   schema: GraphQLSchema,
-  fieldName: string
+  fieldName: string,
+  transforms: Array<Transform>
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
 ): ISchemaLevelResolver<any, any> => {
   return async (parent, args, context, info) =>
@@ -16,7 +24,7 @@ const createResolverWithoutVideoViewsField = (
       args,
       context,
       info,
-      transforms: [RemoveQueryNodeViewsField],
+      transforms,
     })
 }
 
@@ -25,10 +33,17 @@ export const queryNodeStitchingResolvers = (
   orionSchema: GraphQLSchema
 ): IResolvers => ({
   Query: {
-    videosConnection: createResolverWithoutVideoViewsField(queryNodeSchema, 'videosConnection'),
-    featuredVideos: createResolverWithoutVideoViewsField(queryNodeSchema, 'featuredVideos'),
-    search: createResolverWithoutVideoViewsField(queryNodeSchema, 'search'),
-    video: createResolverWithoutVideoViewsField(queryNodeSchema, 'video'),
+    videosConnection: createResolverWithTransforms(queryNodeSchema, 'videosConnection', [RemoveQueryNodeViewsField]),
+    featuredVideos: createResolverWithTransforms(queryNodeSchema, 'featuredVideos', [RemoveQueryNodeViewsField]),
+    search: createResolverWithTransforms(queryNodeSchema, 'search', [
+      RemoveQueryNodeViewsField,
+      RemoveQueryNodeFollowsField,
+    ]),
+    video: createResolverWithTransforms(queryNodeSchema, 'video', [RemoveQueryNodeViewsField]),
+    channelsConnection: createResolverWithTransforms(queryNodeSchema, 'channelsConnection', [
+      RemoveQueryNodeFollowsField,
+    ]),
+    channel: createResolverWithTransforms(queryNodeSchema, 'channel', [RemoveQueryNodeFollowsField]),
   },
   Video: {
     // TODO: Resolve the views count in parallel to the videosConnection query
@@ -53,4 +68,24 @@ export const queryNodeStitchingResolvers = (
       }
     },
   },
+  Channel: {
+    follows: async (parent, args, context, info) => {
+      try {
+        return await delegateToSchema({
+          schema: orionSchema,
+          operation: 'query',
+          fieldName: ORION_FOLLOWS_QUERY_NAME,
+          args: {
+            channelId: parent.id,
+          },
+          context,
+          info,
+          transforms: [TransformOrionFollowsField],
+        })
+      } catch (error) {
+        console.warn('Failed to resolve follows field', { error })
+        return null
+      }
+    },
+  },
 })

+ 2 - 0
src/api/client/transforms/index.ts

@@ -1,2 +1,4 @@
 export { ORION_VIEWS_QUERY_NAME, TransformOrionViewsField } from './orionViews'
+export { ORION_FOLLOWS_QUERY_NAME, TransformOrionFollowsField } from './orionFollows'
 export { RemoveQueryNodeViewsField } from './queryNodeViews'
+export { RemoveQueryNodeFollowsField } from './queryNodeFollows'

+ 76 - 0
src/api/client/transforms/orionFollows.ts

@@ -0,0 +1,76 @@
+import { Transform } from '@graphql-tools/delegate'
+import { GraphQLError, SelectionSetNode } from 'graphql'
+
+class OrionError extends Error {
+  graphQLErrors: readonly GraphQLError[]
+
+  constructor(errors: readonly GraphQLError[]) {
+    super()
+    this.graphQLErrors = errors
+  }
+}
+
+const CHANNEL_INFO_SELECTION_SET: SelectionSetNode = {
+  kind: 'SelectionSet',
+  selections: [
+    {
+      kind: 'Field',
+      name: {
+        kind: 'Name',
+        value: 'id',
+      },
+    },
+    {
+      kind: 'Field',
+      name: {
+        kind: 'Name',
+        value: 'follows',
+      },
+    },
+  ],
+}
+
+export const ORION_FOLLOWS_QUERY_NAME = 'channelFollows'
+
+// Transform a request to expect ChannelFollowsInfo return type instead of an Int
+export const TransformOrionFollowsField: Transform = {
+  transformRequest(request) {
+    request.document = {
+      ...request.document,
+      definitions: request.document.definitions.map((definition) => {
+        if (definition.kind === 'OperationDefinition') {
+          return {
+            ...definition,
+            selectionSet: {
+              ...definition.selectionSet,
+              selections: definition.selectionSet.selections.map((selection) => {
+                if (selection.kind === 'Field' && selection.name.value === ORION_FOLLOWS_QUERY_NAME) {
+                  return {
+                    ...selection,
+                    selectionSet: CHANNEL_INFO_SELECTION_SET,
+                  }
+                }
+                return selection
+              }),
+            },
+          }
+        }
+        return definition
+      }),
+    }
+
+    return request
+  },
+  transformResult(result) {
+    if (result.errors) {
+      throw new OrionError(result.errors)
+    }
+
+    const follows = result?.data?.[ORION_FOLLOWS_QUERY_NAME]?.follows || 0
+    const data = {
+      channelFollows: follows,
+    }
+
+    return { data }
+  },
+}

+ 25 - 0
src/api/client/transforms/queryNodeFollows.ts

@@ -0,0 +1,25 @@
+import { Transform } from '@graphql-tools/delegate'
+
+// remove follows field from the query node channel request
+export const RemoveQueryNodeFollowsField: Transform = {
+  transformRequest: (request) => {
+    request.document = {
+      ...request.document,
+      definitions: request.document.definitions.map((definition) => {
+        if (definition.kind === 'FragmentDefinition' && definition.name.value === 'AllChannelFields') {
+          return {
+            ...definition,
+            selectionSet: {
+              ...definition.selectionSet,
+              selections: definition.selectionSet.selections.filter((selection) => {
+                return selection.kind !== 'Field' || selection.name.value !== 'follows'
+              }),
+            },
+          }
+        }
+        return definition
+      }),
+    }
+    return request
+  },
+}

+ 1 - 0
src/api/queries/__generated__/AllChannelFields.ts

@@ -13,4 +13,5 @@ export interface AllChannelFields {
   handle: string;
   avatarPhotoUrl: string | null;
   coverPhotoUrl: string | null;
+  follows: number | null;
 }

+ 1 - 0
src/api/queries/__generated__/GetChannel.ts

@@ -13,6 +13,7 @@ export interface GetChannel_channel {
   handle: string;
   avatarPhotoUrl: string | null;
   coverPhotoUrl: string | null;
+  follows: number | null;
 }
 
 export interface GetChannel {

+ 1 - 0
src/api/queries/__generated__/GetNewestChannels.ts

@@ -13,6 +13,7 @@ export interface GetNewestChannels_channelsConnection_edges_node {
   handle: string;
   avatarPhotoUrl: string | null;
   coverPhotoUrl: string | null;
+  follows: number | null;
 }
 
 export interface GetNewestChannels_channelsConnection_edges {

+ 1 - 0
src/api/queries/channels.ts

@@ -14,6 +14,7 @@ export const allChannelFieldsFragment = gql`
     handle
     avatarPhotoUrl
     coverPhotoUrl
+    follows
   }
 `
 

+ 2 - 0
src/api/schemas/extendedQueryNode.graphql

@@ -35,6 +35,8 @@ type Channel {
   language: Language
 
   videos: [Video!]!
+  # extended from Orion
+  follows: Int
 }
 
 type Category {

+ 50 - 10
src/mocking/data/raw/channels.json

@@ -1,12 +1,52 @@
 [
-  { "id": "e619b9e6-f7b5-4624-979f-2ddefbdead26", "handle": "mollitia quasi odit rerum" },
-  { "id": "e5849d9e-0096-4bc1-97f5-2eb5ffbca99a", "handle": "eligendi consequatur" },
-  { "id": "88d3f52e-de3e-4b62-8a89-b336ce8dd73d", "handle": "id vel" },
-  { "id": "d1a9d99c-5a79-4956-ae69-edbc769fe347", "handle": "explicabo officia" },
-  { "id": "8d29f059-d403-4145-a4f2-ab6e1ad0088a", "handle": "fugit et et" },
-  { "id": "ff0eb9cc-5421-4648-aa31-e1a6c7ff25e5", "handle": "delectus voluptas veniam aut" },
-  { "id": "f460ecd2-f76a-47bc-9489-2a36b40c2fe2", "handle": "voluptatibus saepe" },
-  { "id": "28e58b60-6647-41b0-8bb1-3562f927e56f", "handle": "ut iste quia dolorem" },
-  { "id": "e7db3770-f3d0-4ea6-b491-0e369dc91279", "handle": "molestias ab optio sit" },
-  { "id": "f3eb7b52-e94b-48df-a16c-6e3c679f6d9e", "handle": "harum molestias aut dolore" }
+  {
+    "id": "0d75be46-9e81-4d79-a38f-753fbec0adf6",
+    "handle": "consequatur pariatur quidem",
+    "follows": 2035
+  },
+  {
+    "id": "af8e1acc-cec4-4b3f-962f-22899f9bb617",
+    "handle": "modi sequi",
+    "follows": 6931
+  },
+  {
+    "id": "7770f5a6-2313-4a46-a562-c5b708ea20ba",
+    "handle": "quo quos tenetur",
+    "follows": 25936
+  },
+  {
+    "id": "0365f355-1fbe-4d61-b717-3f8aec7d65f9",
+    "handle": "autem eligendi",
+    "follows": 3740
+  },
+  {
+    "id": "0c9a00b9-960b-4d48-89a9-67a6c87e66a6",
+    "handle": "dolore distinctio",
+    "follows": 17040
+  },
+  {
+    "id": "accdd79f-ac9b-4bb7-9537-4035e5d62b80",
+    "handle": "cum voluptate nesciunt",
+    "follows": 8914
+  },
+  {
+    "id": "bf163a58-daf0-41a9-b805-c16ad0ef480c",
+    "handle": "possimus voluptas",
+    "follows": 89479
+  },
+  {
+    "id": "b4c0435e-f01b-41a8-884f-09568047e5cf",
+    "handle": "aut architecto et",
+    "follows": 93124
+  },
+  {
+    "id": "3314712a-554c-4a3c-a2bf-467db1a91f38",
+    "handle": "est beatae sunt",
+    "follows": 138870
+  },
+  {
+    "id": "2a7398f8-745f-44e1-976b-de9a018a940f",
+    "handle": "error aut qui alias",
+    "follows": 120905
+  }
 ]

+ 2 - 1
src/mocking/data/raw/coverVideo.json

@@ -22,7 +22,8 @@
     "id": "102",
     "handle": "SCHISM",
     "avatarPhotoUrl": "https://eu-central-1.linodeobjects.com/joystream/schismavatar_black.png",
-    "coverPhotoUrl": null
+    "coverPhotoUrl": null,
+    "follows": 48717
   },
   "cover": {
     "coverDescription": "How We Lost Trust In Authority, And Authority Taught Us To Distrust Ourselves",

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

@@ -17,6 +17,10 @@ type MirageJSServer = any
 
 export const createMockData = (server: MirageJSServer) => {
   const channels = mockChannels.map((channel) => {
+    server.create('ChannelFollowsInfo', {
+      id: channel.id,
+      follows: channel.follows,
+    })
     return server.schema.create('Channel', {
       ...channel,
     }) as ModelInstance<AllChannelFields>

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

@@ -15,6 +15,7 @@ import {
   videoResolver,
   videosResolver,
   videoViewsResolver,
+  channelFollowsResolver,
 } from './resolvers'
 import {
   MOCK_ORION_GRAPHQL_URL,
@@ -44,6 +45,7 @@ createServer({
       resolvers: {
         Query: {
           videoViews: videoViewsResolver,
+          channelFollows: channelFollowsResolver,
         },
         Mutation: {
           addVideoView: addVideoViewResolver,

+ 13 - 5
src/mocking/server/resolvers.ts

@@ -107,6 +107,14 @@ export const channelsResolver: QueryResolver<GetNewestChannelsVariables, GetNewe
   return paginatedChannels
 }
 
+type ChannelFollowsArgs = {
+  channelId: string
+}
+
+export const channelFollowsResolver: QueryResolver<ChannelFollowsArgs> = (obj, args, context, info) => {
+  return mirageGraphQLFieldResolver(obj, { id: args.channelId }, context, info)
+}
+
 type VideoModel = { attrs: VideoFields }
 type ChannelModel = { attrs: AllChannelFields }
 type SearchResolverResult = Omit<Search_search, 'item'> & { item: VideoModel | ChannelModel }
@@ -154,18 +162,18 @@ export const searchResolver: QueryResolver<SearchVariables, SearchResolverResult
 }
 
 type VideoViewsArgs = {
-  videoID: string
+  videoId: string
 }
 
 export const videoViewsResolver: QueryResolver<VideoViewsArgs> = (obj, args, context, info) => {
-  return mirageGraphQLFieldResolver(obj, { id: args.videoID }, context, info)
+  return mirageGraphQLFieldResolver(obj, { id: args.videoId }, context, info)
 }
 
 export const addVideoViewResolver: QueryResolver<VideoViewsArgs> = (obj, args, context, info) => {
-  const videoInfo = context.mirageSchema.videoViewsInfos.find(args.videoID)
+  const videoInfo = context.mirageSchema.entityViewsInfos.find(args.videoId)
   if (!videoInfo) {
-    const videoInfo = context.mirageSchema.videoViewsInfos.create({
-      id: args.videoID,
+    const videoInfo = context.mirageSchema.entityViewsInfos.create({
+      id: args.videoId,
       views: 1,
     })
 

+ 25 - 9
src/views/ChannelView/ChannelView.style.tsx

@@ -4,8 +4,10 @@ import { Placeholder, Text } from '@/shared/components'
 import ChannelLink from '@/components/ChannelLink'
 import { breakpoints, colors, sizes, typography, zIndex } from '@/shared/theme'
 
-const SM_TITLE_HEIGHT = '48px'
-const TITLE_HEIGHT = '56px'
+const SM_TITLE_HEIGHT = '44px'
+const TITLE_HEIGHT = '51px'
+const SM_SUBTITLE_HEIGHT = '24px'
+const SUBTITLE_HEIGHT = '27px'
 
 const CONTENT_OVERLAP_MAP = {
   BASE: 0,
@@ -159,17 +161,13 @@ export const TitleContainer = styled.div`
   @media screen and (min-width: ${breakpoints.medium}) {
     max-width: 60%;
   }
-  background-color: ${colors.gray[800]};
-  padding: 0 ${sizes(2)};
 `
 
 export const Title = styled(Text)`
+  line-height: 1;
+  padding: ${sizes(1)} ${sizes(2)} ${sizes(2)};
   ${fluidRange({ prop: 'fontSize', fromSize: '32px', toSize: '40px' })};
-  margin: -4px 0 0;
-  line-height: ${SM_TITLE_HEIGHT};
-  @media screen and (min-width: ${breakpoints.medium}) {
-    line-height: ${TITLE_HEIGHT};
-  }
+  background-color: ${colors.gray[800]};
 
   white-space: nowrap;
   text-overflow: ellipsis;
@@ -177,6 +175,15 @@ export const Title = styled(Text)`
   max-width: 600px;
 `
 
+export const SubTitle = styled(Text)`
+  padding: ${sizes(1)} ${sizes(2)};
+  ${fluidRange({ prop: 'fontSize', fromSize: '14px', toSize: '18px' })};
+  margin-top: ${sizes(2)};
+  color: ${colors.white};
+  background-color: ${colors.gray[800]};
+  display: inline-block;
+`
+
 export const VideoSection = styled.section`
   position: relative;
 `
@@ -200,3 +207,12 @@ export const TitlePlaceholder = styled(Placeholder)`
     height: ${TITLE_HEIGHT};
   }
 `
+
+export const SubTitlePlaceholder = styled(Placeholder)`
+  width: 140px;
+  margin-top: ${sizes(2)};
+  height: ${SM_SUBTITLE_HEIGHT};
+  @media screen and (min-width: ${breakpoints.medium}) {
+    height: ${SUBTITLE_HEIGHT};
+  }
+`

+ 16 - 7
src/views/ChannelView/ChannelView.tsx

@@ -16,11 +16,14 @@ import {
   TitlePlaceholder,
   TitleSection,
   VideoSection,
+  SubTitle,
+  SubTitlePlaceholder,
 } from './ChannelView.style'
 import { BackgroundPattern } from '@/components'
 import { InfiniteVideoGrid } from '@/shared/components'
 import { CSSTransition, TransitionGroup } from 'react-transition-group'
 import { transitions } from '@/shared/theme'
+import { formatNumberShort } from '@/utils/number'
 
 const ChannelView: React.FC<RouteComponentProps> = () => {
   const { id } = useParams()
@@ -56,13 +59,19 @@ const ChannelView: React.FC<RouteComponentProps> = () => {
         </MediaWrapper>
         <TitleSection>
           <StyledChannelLink id={data?.channel?.id} avatarSize="view" hideHandle noLink />
-          {data?.channel ? (
-            <TitleContainer>
-              <Title variant="h1">{data.channel.handle}</Title>
-            </TitleContainer>
-          ) : (
-            <TitlePlaceholder />
-          )}
+          <TitleContainer>
+            {data?.channel ? (
+              <>
+                <Title variant="h1">{data.channel.handle}</Title>
+                <SubTitle>{data.channel.follows ? formatNumberShort(data.channel.follows) : 0} Followers</SubTitle>
+              </>
+            ) : (
+              <>
+                <TitlePlaceholder />
+                <SubTitlePlaceholder />
+              </>
+            )}
+          </TitleContainer>
         </TitleSection>
       </Header>
       <VideoSection>