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:
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.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 thelist collection
and the button Create orSubmit Collection
. If a user does not have alist collection
, the user can create a new collection and will be directed to the popupcreate collection
. After the user selects the collection andsubmit
, the video will display the collection list on his card.Create Collection: Displays popups containing form
title
andlink image
. After submitting, the collection will be added to the database.Video Details: Displays
rank
,rates
,episodes
,collections
,categories
,release dates
andvideo descriptions
. In addition, users can add videos to the collection by clickingAdd to Collection
and popup.
I made this project using:
-
NextJs: I chose to build this application using
NextJs
, because it's quick and easy to set up. -
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
, andschema
. Using GraphSQL, developers can call the data they need through a query. - Apollo: To handle requests from the client side when using GraphQL, we can use Apollo servers.
- Mysql: Since I am quite familiar with SQL query and supports various types of data types, I chose to implement MYSQL in this project.
- Typeorm: TypeORM makes it easy to build an ORM-based application using an object-oriented programming paradigm.
-
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]
}
`;
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 {};
}
}
}
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;
}
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();
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;
}
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))
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>
);
}
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
}
}
}
`;
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
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)