DEV Community

Bayu Syaits
Bayu Syaits

Posted on

Build a Full-stack App Video Collections Using NextJs, Apollo.io Client, Graphql, Mysql, Typeorm and ExpressJs

Image 1, Tech Stack Bayu Syaits

Introduction

In this article I'm going to explain the code on the Video Collections app that I've created. It's a way for me to better understand the code that I have created and a way to exchange ideas with my developer friends. Before discussing technical issues, I'm going to list some of the features available in this app, including:

  1. Video List: Shows the entire video, at least 10 videos displayed. If there is more than that user can click Load More to add 10 videos to display. The Video Card must contain: Checkbox, title, image, short description, collections and categories. If the card is clicked, it can lead to the detail page. If the checkbox is on the Add to Collection text on the active Sidebar.

  2. Add to Collection: This button will enable if the user has selected several videos and can add to collection. If the user clicks on this button, it will display a popup containing the list collection and the button Create or Submit Collection. If a user does not have a list collection, the user can create a new collection and will be directed to the popup create collection. After the user selects the collection and submit, the video will display the collection list on his card.

  3. Create Collection: Displays popups containing form title and link image. After submitting, the collection will be added to the database.

  4. Video Details: Displays rank, rates, episodes, collections, categories, release dates and video descriptions. In addition, users can add videos to the collection by clicking Add to Collection and popup.

I made this project using:

  1. NextJs: I chose to build this application using NextJs, because it's quick and easy to set up.
  2. Graphql: Developed by the Facebook team in 2012, aims to get a more flexible and efficient API. GraphQL itself is built on three main foundations: query, resolver, and schema. Using GraphSQL, developers can call the data they need through a query.
  3. Apollo: To handle requests from the client side when using GraphQL, we can use Apollo servers.
  4. Mysql: Since I am quite familiar with SQL query and supports various types of data types, I chose to implement MYSQL in this project.
  5. Typeorm: TypeORM makes it easy to build an ORM-based application using an object-oriented programming paradigm.
  6. ExpressJs: I need a flexible framework but also a scalable one, for which I'm using expressJs that's pretty popular with NodeJs users.

Implementation API

The first folder I'm going to discuss is the api folder. It contains entities, and services whose names represent the table name, namely category, collection, user, video, video-category and video-collections. Each of these folders contains typeDefs.ts, args.ts, entity.ts and resolvers.ts files. I'll explain briefly about the files.

The first is typeDefs.ts, which contains data forms that are defined in the GraphQL schema language. The schema itself consists of one or more types such as Object Type, Scalar Type, Query Type, Mutation Type and Input Type. Here's a piece of code on the typeDEFS file in the video folder.

export const typeDefs = `
  extend type Query {
    getVideo(slug: String!): Video
  }

  extend type Mutation {
    addVideo(payload: PayloadAddVideo): Video!
  }

  type Gallery {
    image: String
  }

  input PayloadAddVideo {
    title: String!
    image: String!
    episode: Int
    description: String
    isCencor: Int
    rates: Int
    rank: Int
    type: String
  }

  type Video {
    uuid: String!
    title: String!
    slug: String!
    image: String
    gallery: [Gallery]
    videoCollections: [VideoCollection]
    videoCategories: [VideoCategory]
  }
`;

Enter fullscreen mode Exit fullscreen mode

The Apollo server needs to know how to fill in the data in the schema in order to be able to respond to the client's request. To this, a resolution is required, following this piece of code in the file resolver.ts on the video folder.

import { isEmpty } from "lodash";
import { v4 } from "uuid"
import Args, { VideoResponse } from "./args";
import { AppDataSource } from "../../data-source"
import { Video as VideoEntity } from "./entity";
import { filterItems } from "../../helpers/filter";
import { generateKey, setSpaceToDash } from "../../helpers/mixins";
import slugify from "../../helpers/slugify";

// Provide resolver functions for your schema fields
export const Query = {
  getVideo: async (_: any, args: any) => {
    const { slug } = args;
    if (!slug) {
      return null;
    }
    const videoEntity = AppDataSource.getRepository(VideoEntity)
    return await videoEntity.findOne({ 
      relations: {
        videoCollections: true,
        videoCategories: true
      },
      where: { 
        slug: setSpaceToDash(slug)
      } 
    });
  }
}

export const Mutation = {
  addVideo: async (_: any, args: any) => {
    try {
      const generate = Math.floor(generateKey(100))
      const { 
        payload: {
          title, image, episode, rates, rank,
          status, description, gallery
        }
      } = args;
      const video = new VideoEntity()
      video.uuid = v4()
      if (episode) {
        video.episode = episode
      }
      if (rates) {
        video.rates = rates
      }
      if (rank) {
        video.rank = rank
      }
      if (description) {
        video.description = description
      }
      if (status && ['editorial','favorite'].includes(status)) {
        video.status = status
      }
      if (gallery && Array.isArray(gallery)) {
        video.gallery = gallery
      }
      video.slug = slugify(title) ? `${setSpaceToDash(slugify(title))}_${generate}` : 
      `${setSpaceToDash(title)}_${generate}`
      video.image = image
      const videoRepository = AppDataSource.getRepository(VideoEntity)
      return await videoRepository.save(video);
    } catch (error) {
      return {};
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

The next file is args.ts, I called the type-graphql library to use some decorators like Argstype, Field and ObjectType to define GraphQL schemes easily.
We can associate Class Args properties with fields (limit, offset, type, dst) in a GraphQL scheme using Fields. Whereas to define the argument type in the resolver of a class, we can use Argstype and to associate the object type can use ObjectType. Here's a piece of code on the args.ts file in the video folder:

import { Field, ArgsType, ObjectType } from 'type-graphql';
@ArgsType()
export default class Args {
  @Field({ defaultValue: 10 })
  limit: number;

  @Field({ defaultValue: 0 })
  offset: number;

  @Field({ nullable: true })
  type?: string;

  @Field({ nullable: true })
  sortBy?: string;

  @Field({ nullable: true })
  search?: string;

  @Field({ nullable: true })
  slug?: string;
}

@ObjectType()
export class Gallery {
  @Field()
  image: string;
}

Enter fullscreen mode Exit fullscreen mode

Each typeDefs.ts and resolvers.ts file will be linked via server.app.ts, below is an example of how to connect User and Category services with apollo-server-express and integrate them into ExpressJs.

import "reflect-metadata";
import * as express from "express";
import { ApolloServer, makeExecutableSchema } from "apollo-server-express";

import { merge } from "lodash";
import { typeDefs as UserTypeDefs } from "./app/services/user/typeDefs";
import { typeDefs as CategoryTypeDefs } from "./app/services/category/typeDefs";
import {
   Query as UserQuery,
   Mutation as UserMutations
} from "./app/services/user/resolvers";
import {
  Query as CategoryQuery,
  Mutation as CategoryMutations
} from "./app/services/category/resolvers";
const startServer = async () => {
  const schema = makeExecutableSchema({
    typeDefs: [ 
      CategoryTypeDefs,
      UserTypeDefs,
      VideoTypeDefs,
      CollectionTypeDefs,
      VideoCollectionTypeDefs,
      VideoCategoryTypeDefs
    ],
    resolvers: merge(
      {
        Query: {
          ...CategoryQuery,
          ...UserQuery,
        },
        Mutation: {
          ...CategoryMutations,
          ...UserMutations,
        }
      }
    ),
  });
  const server = new ApolloServer({ 
    schema,
    introspection: true,
    playground: true,
    tracing: true,
  });

  const app: express.Application = express();

  server.applyMiddleware({ app });

  app.listen({ port: PORT }, () =>
    console.log(`🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`)
  );
};

startServer();
Enter fullscreen mode Exit fullscreen mode

The last part is the entity.ts file, in this project I use a typeorm which is a representation of a table in a relational database. By defining a class in this Entity file, TypeORM will automatically create a table with a schema defined in the Video class according to the decorator defined on each column, and we can use TypeORN to perform CRUD operations without having to write SQL queries directly.

import { 
  Entity, 
  Column, 
  PrimaryColumn,
  Generated,
  BaseEntity,
  OneToMany,
  JoinColumn,
  DeleteDateColumn,
  CreateDateColumn,
  UpdateDateColumn,
} from "typeorm";

import { VideoCollection } from "../video-collection/entity";
import { VideoCategory } from "../video-category/entity";

export enum Status {
  EDITORIAL = "editorial",
  FAVORITE = "favorite",
}

type Gallery = {
  image: string
}

@Entity()
export class Video extends BaseEntity {
  @PrimaryColumn("char", {length: 100})
  @Generated("uuid")
  uuid: string

  @Column("char", { length: 50 })
  title: string;

  @OneToMany(() => VideoCollection, 
    (videoCollection) => videoCollection.videoUuid,
    {
      cascade: ["insert", "update"],
    })
  @JoinColumn([
      { name: "uuid" }
  ])
  public videoCollections: VideoCollection[]

  @OneToMany(() => VideoCategory, 
    (videoCategory) => videoCategory.videoUuid,
    {
      cascade: ["insert", "update"],
    })
  @JoinColumn([
      { name: "uuid" }
  ])

  public videoCategories: VideoCategory[]

  @Column("text", {nullable: true})
  description: string;

  @Column({ type: "int", nullable: true })
  episode: number;

  @Column({ type: "int", nullable: true })
  rates: number;

  @Column({ type: "int", nullable: true })
  rank: number;

  @Column({ type: "int", default: 0 })
  isCencor: number;

  @Column("char", { length: 20, nullable: true })
  type: string;

  @Column({ type: "simple-json", default: "", nullable: true })
  gallery: Gallery[];

  @Column({default: null, nullable: true})
  image: string;

  @Column("char", { length: 60 })
  slug: string;

  @Column({
    type: "enum",
    enum: Status,
    default: Status.FAVORITE
  })
  status: Status

  @Column({ type: 'date', nullable: true })
  publishDate: Date;

  @CreateDateColumn()
  createdDate: Date;

  @UpdateDateColumn()
  updateDate: Date;

  @DeleteDateColumn()
  deletedDate: Date;
}
Enter fullscreen mode Exit fullscreen mode

All entity files in each service folder are connected using DataSource. On the code below is an example of the dataSource configuration to connect to the MySQL database, in the data-source.ts file.

import "reflect-metadata"
import { DataSource } from "typeorm"
import { User } from "./services/user/entity"
import { Category } from "./services/category/entity"
import { Video } from "./services/video/entity"
import { Collection } from "./services/collection/entity"
import { VideoCollection } from "./services/video-collection/entity"
import { VideoCategory } from "./services/video-category/entity"

export const AppDataSource = new DataSource({
    type: "mysql",
    host: "127.0.0.1",
    port: 3306,
    username: "root",
    password: "xxxxx",
    database: "db_collections",
    synchronize: true,
    logging: false,
    entities: [
        User, 
        Category, 
        Video, 
        Collection, 
        VideoCollection,
        VideoCategory
    ],
    migrations: [],
    subscribers: [],
})
AppDataSource.initialize()
    .catch((error) => console.log(error))

Enter fullscreen mode Exit fullscreen mode

Implementation APP

This app is built using NextJs, I just follow the documentation and use the features available on this framework. However, I've added some libraries like material-ui, GraphQL, apollo and react-hook-form to speed up the development process and adapt to the needs of this project.
If in the api folder I have described the use of Apollo and GraphQL on the server side, then in this app folder I will describe the use on the client side. First, on the _app.tsx file we need to configure and redirect to the GraphQL server endpoint that has been created in the api folder. Then we can integrate the Apollo Client with the React application using ApolloProvider and connect it to the function client. For more information, you can see the code examples below:

import type { AppProps } from "next/app";
import { useEffect, useState } from "react";
import { unregister } from "next-offline/runtime";
import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import theme from "hooks/theme";
import { sha256 } from 'crypto-hash';

import Offline from "./offline";
import Layout from "base-components/layout";
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
import { createHttpLink } from 'apollo-link-http';
import { REACT_APP_API_URL } from "config";
import { AppBar, Grid, Toolbar } from "@mui/material";
import Typography from '@mui/material/Typography';
import { Box, Container } from "@mui/system";
import Link from "next/link";

const httpLink = createPersistedQueryLink({ sha256 }).concat(
  new createHttpLink({ uri: REACT_APP_API_URL }),
);

const client = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
});

export default function MyApp({
  Component,
  emotionCache = clientSideEmotionCache,
  pageProps,
}: AppProps & {
  emotionCache: any;
}) {
  const [isDisconnected, setDisconnected] = useState<boolean>(false);
  const handleConnectionChange = () => {
    if (!navigator.onLine) {
      setDisconnected(true);
    } else {
      setDisconnected(false);
    }
  };
  useEffect(() => {
    unregister();
    const style: any = document.getElementById("server-side-styles");
    if (style) {
      style.parentNode.removeChild(style);
    }
    window.addEventListener("offline", handleConnectionChange);
    window.addEventListener("online", handleConnectionChange);
  });
  const setComponent = () => {
    let d: any = <Component {...pageProps} />;
    if (isDisconnected) {
      d = <Offline />;
    }
    return d;
  };
  return (
    <ApolloProvider client={client as any}>
        <Layout>
          <ThemeProvider theme={theme}>
            <CssBaseline />
            <AppBar position="relative">
              <Toolbar
                sx={{
                  pr: '24px',
                }}
              >
                <Link 
                  style={{
                    textDecoration: 'none'
                  }}
                  href="/">
                  <Typography
                    component="h1"
                    variant="h6"
                    color="inherit"
                    noWrap
                    sx={{ 
                      flexGrow: 1, 
                      color: 'white'
                    }}
                  >
                      Video List
                  </Typography>
                </Link>
              </Toolbar>
            </AppBar>
            <Box>
              <Container maxWidth="lg" sx={{ px: '24px', mt: 4, mb: 4 }}>
                <Grid container spacing={1}>
                  {setComponent()}
                </Grid>
              </Container>
            </Box>
          </ThemeProvider>
        </Layout>
    </ApolloProvider>
  );
}

Enter fullscreen mode Exit fullscreen mode

Implementation Query & Mutation

After config, the most common step is to use the Apollo Client with a query and a GraphQL mutation on each component. Below is an example in the queries.ts file in the app folder > components > video > details.

In the GET_VIDEO query, I have called the JoinTable column (videoCategories dan videoCollections). When dealing with this, we can implement resolvers to call field joinTable TypeORM through GraphQL. In this case, I take the value uuid in the column videoCategories and videoCollections. The rest of the values that I'm calling adjust to the needs in each column.

In the PUT_BULK_VIDEO_COLLECTION mutation section, I call PayloadBulkVideoCollect which is the Input Type. This Input type is called in a mutation to define the type of data that can be sent as an argument in the bulkVideoCollection operation to be sent to the server. These Input Types are very useful in GraphQL, as they allow developers to organize arguments well.

import gql from "graphql-tag";
export const PUT_BULK_VIDEO_COLLECTION = gql`
  mutation bulkVideoCollection(
    $payload: [PayloadBulkVideoCollection!]!
  ) {
    bulkVideoCollection(
      payload: $payload
    ) {
      uuid
      userUuid
    }
  }
`;
export const DELETE_VIDEO_COLLECTION = gql`
  mutation deleteVideoCollection(
    $uuid: String!
    $userUuid: String!
  ) {
    deleteVideoCollection(
      uuid: $uuid
      userUuid: $userUuid
    )
  }
`;
export const GET_VIDEO = gql`
  query getVideo(
    $slug: String!
  ) {
    getVideo(
      slug: $slug
    ) {
      uuid
      title
      slug
      episode
      description
      type
      image
      videoCategories {
        id
        uuid
        categoryUuid {
          title
        }
      }
      videoCollections {
        uuid
        collectionUuid {
          title
          uuid
        }
        id
      }
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

Implementation useQuery dan useMutation

There are two hooks provided by the Apollo Client library, useQuery and useMutation. Below is the code in the components > video > details > index.tsx. One of the advantages of using an apollo client is that developers can control the data taken from the cache and/ or from the server by adding the fetchPolicy option.

Values for fetchPolicy options are numerous, including: cache-first, cache-and-network, network-only, cache, no-catch and standby. For an explanation of this value, you can read on this link.

In addition, when using useQuery we can also deal with loading and errors that may occur during requests to the server, or perform refetch (method used to reload data from the server with GraphQL query) if successful request on useMutation.

The next hook is useMutation, in the code below I call the mutation DELETE_VIDEO_COLLECTION. Mutations in GraphQL are used to send data to servers to update data, just as useQuery in useMutation can also handle loading, error during request to the server and retrieve data.

deleteVideoCollection is a function called to run a mutation. for the payload/ parameter sent as an argument, we can put it in the variables. If the change of data/ mutation is successful then it will reload the data in the GET_VIDEO query by adding the option onCompleted: refetch.

import React, { useContext } from "react";
import { useQuery, useMutation } from '@apollo/client';
import VideoDetailView from "./VideoDetailView";
import VideoDetailSidebarView from "./VideoDetailSidebarView";
import VideoDetailModal from "./modal";
import VideoDetailModalCreate from "./modal-create";
import { useModal, ModalPopupDispatchContext } from "hoc/withModal";
import { useRouter } from "next/router";
import { Grid } from "@mui/material";
import { debounce } from "lodash";
import { GET_VIDEO, DELETE_VIDEO_COLLECTION } from './queries'

type VideoProps = {};
const VideoDetailContainer: React.FC<VideoProps> = () => {
  const router = useRouter();
  const {
    query: {
      slug
    }
  }: any = router
  const { loading, error, data, refetch } = useQuery(GET_VIDEO, {
    fetchPolicy: "cache-and-network",
    variables: {
      slug
    },
  }) 
  const { openModal } = useModal();
  const { closeModal, onSubmitModal } = useContext(ModalPopupDispatchContext);
  const [deleteVideoCollection, {}] = useMutation(DELETE_VIDEO_COLLECTION, {
    onCompleted: refetch,
    awaitRefetchQueries: true
  });
  const handleRemoveCollection = (val: string)  => () => {
    deleteVideoCollection({
      variables: {
          uuid: val,
          userUuid: 'de4e31bd-393d-40f7-86ae-ce8e25d81b00'
        } 
      },
    )
    .catch((err: any) => {
      console.log('[011] err', err)
    });
  }
  const openModalAddCollection = debounce(() => {
    const onFinish = () => {
      onSubmitModal();
    };
    const onSwitch = () => {
      closeModal();
      openModalCreateCollection()
    }
    openModal({
      title: "Add to Collection",
      hideClose: false,
      component: () => (
        <VideoDetailModal
          onFinish={onFinish}
          onSwitch={onSwitch}
          field={{...data.getVideo}}
        />
      ),
      onClose: () => {
        closeModal();
      },
    });
  }, 1000);
  const openModalCreateCollection = debounce(() => {
    const onFinish = () => {
      onSubmitModal();
      openModalAddCollection()
    };
    const onSwitch = () => {
      closeModal();
      openModalAddCollection()
    }
    openModal({
      title: "Create Collection",
      hideClose: false,
      component: () => (
        <VideoDetailModalCreate
          onFinish={onFinish}
          onSwitch={onSwitch}
        />
      ),
      onClose: () => {
        closeModal();
      },
    });
  }, 1000);
  const handleAddCollection = (e: React.FormEvent<HTMLFormElement>) => {
    if (data?.getVideo) {
      openModalAddCollection()
    }
  }
  const handlerList = {
    error,
    loading,
    data,
    handleRemoveCollection
  }

  const handlerCollection = {
    handleAddCollection,
    error,
    loading,
    data
  }

  return (
    <Grid 
        container 
        rowSpacing={3} 
        columnSpacing={{ xs: 2, sm: 2, md: 2, lg: 2 }}
        justifyContent="start"
        alignItems="start"
      >
      <Grid item lg={3} xl={3} xs={12} sm={12} md={3}>
        <VideoDetailSidebarView {...handlerCollection} />
      </Grid>
      <Grid item lg={9} xl={9} xs={12} sm={12} md={9}>
        <VideoDetailView {...handlerList} />
      </Grid>
    </Grid>
  );
}

export default VideoDetailContainer
Enter fullscreen mode Exit fullscreen mode

File Query SQL

The last folder in this project, db_collections, contains SQL queries to create tables and mock data. This is to make it easier for friends who want to try this app without having to make data samples from scratch.

Conclution

GraphQL can be an alternative if friends want to build applications instead of using REST-API. One of its advantages is that developers can call the required data through a query and I also use apollo to handle requests from the client side. The library that I use in this project is very helpful in the development process in addition to the flexibility and scalability factors, the project becomes faster in developing with the library-library that I have explained above.

If there are errors or there is a way/ technique that friends think is better when implementing the above material, please comment below or you can send a message to me on linkedIn. For friends who want to learn more and explore, please check my github. Once you clone, and install depedency in each folder (api/ app), you can run by following the commands in the scripts in each packages.json.

Top comments (0)