All Articles

Creating a Reddit Clone Using React and GraphQL - 19

inner peace

From last post, we stopped with a bug that introduce to the system. If we check the console in API we can see there is an error.

error: bind message supplies 2 parameters, but prepared statement "" requires 1

The thing is, in the post resolver, we are providing 2 parameters in the replacement array. But in the query, we only have $1. Because if the user just scroll the home page without login to the system, there will not be an UserId . So we need to change the parameters dynamically according to the query.

So first change the replacement array.

const replacement: any[] = [realLimitPlusOne];

Then set the cursor index dynamically.

if (req.session.userId) {
  replacement.push(req.session.userId);
}
let cursorIdx = 3;
if (cursor) {
  replacement.push(new Date(parseInt(cursor)));
  cursorIdx = replacement.length;
}

Then we can change the query like this.

SELECT p.*,
json_build_object(
'id', u.id,
'username', u.username,
'email', u.email
) creator,
${
req.session.userId
? '(select value from upvote where "userId" = $2 and "postId" = p.id) "voteStatus"'
: 'null as "voteStatus"'
}
FROM post p
INNER JOIN public.user u on u.id = p."creatorId"
// changeing the cursorIdx dynamically
${cursor ? ` WHERE  p."createdAt" < $${cursorIdx}` : ""}
ORDER BY p."createdAt" DESC
LIMIT $1

Now we are going to address on next issue in this application. Once you log in and refresh the page, you will not see any upvotes or downvotes that you did.

The reason for it, Once the browser sending a direct request to graphql server, it will attach the cookie. But when we are doing server-side rendering browser first send a request to node.jsback-end then it will send to graphql. While this transition the cookie is dropping from that request.

So, let’s set the cookie in createUrqlClient .

export const createUrqlClient = (ssrExchange: any, ctx: any) => {

// add context as parameter above
// then set the cookie value
let cookie = "";
if (isServer()) {
cookie = ctx?.req?.headers?.cookie;
}
// then set headers in fetchOptions
fetchOptions: {
credentials: "include" as const,
headers: cookie ? { cookie } : undefined,
},

Now we want to show the full post. First, we are going to add the back-end code for return a single post by its id with the creator’s details.

@Query(() => Post, { nullable: true })
post(@Arg("id", () => Int) id: number): Promise<Post | undefined> {
// this should be match with post entity relation's property name
return Post.findOne(id, { relations: ["creator"] });
}

Then we are adding graphql query in front-end.

query Post($id: Int!) {
post(id: $id) {
id
createdAt
updatedAt
title
text
points
voteStatus
creator {
id
username
}
}
}

Now use yarn-gen command to generate the TypeScript mapping methods. After that, we can change the index.tsx page to convert the title to a link.

<NextLink href="/post/[id]" as={`/post/${p.id}`}>
  <Link>
    <Heading fontSize="xl">{p.title}</Heading>
  </Link>
</NextLink>

This will navigate to post page. Now we need to add the Post page. So we create a new folder called pages and add a file [id].tsx page. If you check that above navigation code to post’s details page we are setting the post’s id as url parameter. Because of that, we need to set the page’s name according to that.

We are using 2 utilities here. We can abstract functionality to get the post id from query as below.

export const useGetIntId = () => {
  const router = useRouter();
  const intId =
    typeof router.query.id === "string" ? parseInt(router.query.id) : -1;
  return intId;
};

When the post page loading we need to call for the graphql API to get the full post. To do the we can use below utility.

export const useGetPostFromUrl = () => {
  const intId = useGetIntId();
  return usePostQuery({
    pause: intId === -1,
    variables: {
      id: intId,
    },
  });
};

Now all set for the post page. Let’s add it.

const Post = ({}) => {
  const [{ data, error, fetching }] = useGetPostFromUrl();

  if (fetching) {
    return (
      <Layout>
        <div>loading...</div>
      </Layout>
    );
  }
  if (error) {
    return <div>{error.message}</div>;
  }
  if (!data?.post) {
    return (
      <Layout>
        <Box>could not find post</Box>
      </Layout>
    );
  }
  return (
    <Layout>
      <Heading mb={4}>{data.post.title}</Heading>
      {data.post.text}
    </Layout>
  );
};
export default withUrqlClient(createUrqlClient, { ssr: true })(Post);

Also for the clean structure of the application, we can move the main application link and create post link to navigation bar.

// in NavBar.tsx
body = (
<Flex align="center">
<NextLink href="/create-post">
<Button as={Link} mr={4}>
create post
</Button>
</NextLink>
<Box mr={2}>{data.me.username}</Box>
// ... remaining code goes here
// in return()
return (
<Flex zIndex={1} position="sticky" top={0} bg="tomato" p={4} align="center">
<Flex flex={1} m="auto" align="center" maxW={800}>
<NextLink href="/">
<Link>
<Heading>Reddit Clone</Heading>
</Link>
</NextLink>
<Box ml={"auto"}>{body}</Box>
</Flex>
</Flex>
);

Now we can add delete post functionality. First, we change the functionality that only the post’s owner can delete the post.

@Mutation(() => Boolean)
async deletePost(
@Arg("id", () => Int) id: number,
@Ctx() { req }: RedditDbContext
): Promise<boolean> {
// const post = await Post.findOne(id);
// if (!post) {
//   return false
// }
// if (post.creatorId !== req.session.userId) {
//   throw new Error("not authorize")
// }
// await Upvote.delete({postId: id});
await Post.delete({ id, creatorId: req.session.userId });
return true;
}
}

Also, change the Upvote.ts file to set the cascade delete of upvotes.

// in the Upvote.ts file, add onDelete property
@ManyToOne(() => Post, (post) => post.upvotes, {
onDelete: "CASCADE",
})
post: Post;

In the front-end app let’s add graphql mutation to delete post.

mutation DeletePost($id: Int!) {
deletePost(id: $id)
}

Then we can add the button to delete a post and delete logic into index.tsx file.

const [, deletePost] = useDeletePostMutation();
// below text snippet section
// the full code will be at Github link at the end.
// please check that for more clarity
<IconButton
  icon={<DeleteIcon />}
  aria-label="Delete Post"
  onClick={() => {
    deletePost({ id: p.id });
  }}
/>;

Once we delete a post we need to update the cache. So we are adding this mutation to createUrqlClient .

deletePost: (_result, args, cache, info) => {
cache.invalidate({
__typename: "Post",
id: (args as DeletePostMutationVariables).id,
});
},

Now we are moving to create edit post functionality.

First, we are going to change the back-end code. Here is the updatePost method.

@Mutation(() => Post)
async updatePost(
  @Arg("id", () => Int) id: number,
  @Arg("title", () => String, { nullable: true }) title: string,
  @Arg("text") text: string,
  @Ctx() { req }: RedditDbContext
): Promise<Post | null> {
  const result = await getConnection()
  .createQueryBuilder()
  .update(Post)
  .set({ title, text })
  .where('id = :id and "creatorId" = :creatorId', {
  id,
  creatorId: req.session.userId,
  })
  .returning("*")
  .execute();
  return result.raw[0];
}

Now, in the front-end, we need to have UpdatePost mutation.

mutation UpdatePost($id: Int!, $title: String!, $text: String!) {
  updatePost(id: $id, title: $title, text: $text) {
    id
    title
    text
    textSnippet
  }
}

Then in the index.tsx file we add the link to navigate to edit post page.

<NextLink href="/post/edit/[id]" as={`/post/edit/${p.id}`}>
  <IconButton mr={4} icon={<EditIcon />} aria-label="Edit Post" />
</NextLink>

Now we create a new folder inside the post folder. Then we create [id].tsx file. We can get the post id from url parameter and get the post to edit.

const EditPost = ({}) => {
  const router = useRouter();
  const intId = useGetIntId();
  const [{ data, fetching }] = usePostQuery({
    pause: intId === -1,
    variables: {
      id: intId,
    },
  });
  const [, updatePost] = useUpdatePostMutation();
  if (fetching) {
    return (
      <Layout>
        <div>loading...</div>
      </Layout>
    );
  }

  if (!data?.post) {
    return (
      <Layout>
        <Box>could not find post</Box>
      </Layout>
    );
  }
  return (
    <Layout variant="small">
      <Formik
        initialValues={{ title: data.post.title, text: data.post.text }}
        onSubmit={async (values) => {
          await updatePost({ id: intId, ...values });
          router.push("/");
        }}
      >
        {({ isSubmitting }) => (
          <Form>
            <InputField name="title" placeholder="title" label="Title" />
            <Box mt={4}>
              <InputField
                textarea
                name="text"
                placeholder="text..."
                label="Body"
              />
            </Box>
            <Button
              isLoading={isSubmitting}
              mt={4}
              type="submit"
              colorScheme="teal"
            >
              Update post
            </Button>
          </Form>
        )}
      </Formik>
    </Layout>
  );
};

export default withUrqlClient(createUrqlClient)(EditPost);

Finally, for this post, we only want to add edit and delete buttons for post list, if post owned by log in user. Let’s add that validation to index.tsx file.

// get the current log in user.
const [{ data: meData }] = useMeQuery();
{meData?.me?.id !== p.creator.id ? null : (
// wrap 2 buttons in here.
)
}

Thanks for reading this. If you have anything to ask regarding this please leave a comment here. Also, I wrote this according to my understanding. So if any point is wrong, don’t hesitate to correct me. I really appreciate you. That’s for today friends. See you soon. Thank you.

References:

This article series based on the Ben Award - Fullstack React GraphQL TypeScript Tutorial. This is amazing tutorial and I highly recommend you to check that out.

Main image credit

Web App Web Server