From a4362a531cea9420157c44a5d0d6bce2546281fe Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Mon, 3 Apr 2023 23:32:32 -0400 Subject: [PATCH] Add custom hooks for time distance and retrieving like count Documentation added to all custom hooks --- components/BeerById/BeerInfoHeader.tsx | 49 +++++++++---------- components/BeerById/BeerPostLikeButton.tsx | 9 ++-- components/BeerById/CommentCardBody.tsx | 10 ++-- .../BeerIndex/BeerIndexPaginationBar.tsx | 2 - hooks/useBeerPostComments.ts | 11 +++++ hooks/useBeerPostSearch.ts | 9 +++- hooks/useCheckIfUserLikesBeerPost.ts | 11 +++++ hooks/useGetLikeCount.ts | 48 ++++++++++++++++++ hooks/useRedirectIfLoggedIn.ts | 9 +++- hooks/useTimeDistance.ts | 20 ++++++++ hooks/useUser.ts | 9 ++++ pages/api/beers/[id]/like/index.ts | 27 +++++++++- pages/beers/[id]/index.tsx | 13 +---- 13 files changed, 174 insertions(+), 53 deletions(-) create mode 100644 hooks/useGetLikeCount.ts create mode 100644 hooks/useTimeDistance.ts diff --git a/components/BeerById/BeerInfoHeader.tsx b/components/BeerById/BeerInfoHeader.tsx index 68d18d0..56d6de2 100644 --- a/components/BeerById/BeerInfoHeader.tsx +++ b/components/BeerById/BeerInfoHeader.tsx @@ -1,33 +1,26 @@ import Link from 'next/link'; -import formatDistanceStrict from 'date-fns/formatDistanceStrict'; import format from 'date-fns/format'; -import { FC, useContext, useEffect, useState } from 'react'; +import { FC, useContext } from 'react'; import UserContext from '@/contexts/userContext'; import { FaRegEdit } from 'react-icons/fa'; import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import { z } from 'zod'; +import useGetLikeCount from '@/hooks/useGetLikeCount'; +import useTimeDistance from '@/hooks/useTimeDistance'; import BeerPostLikeButton from './BeerPostLikeButton'; const BeerInfoHeader: FC<{ beerPost: z.infer; - initialLikeCount: number; -}> = ({ beerPost, initialLikeCount }) => { - const createdAtDate = new Date(beerPost.createdAt); - const [timeDistance, setTimeDistance] = useState(''); - const { user } = useContext(UserContext); +}> = ({ beerPost }) => { + const createdAt = new Date(beerPost.createdAt); + const timeDistance = useTimeDistance(createdAt); - const [likeCount, setLikeCount] = useState(initialLikeCount); + const { user } = useContext(UserContext); const idMatches = user && beerPost.postedBy.id === user.id; const isPostOwner = !!(user && idMatches); - useEffect(() => { - setLikeCount(initialLikeCount); - }, [initialLikeCount]); - - useEffect(() => { - setTimeDistance(formatDistanceStrict(new Date(beerPost.createdAt), new Date())); - }, [beerPost.createdAt]); + const { likeCount, mutate } = useGetLikeCount(beerPost.id); return (
@@ -62,12 +55,14 @@ const BeerInfoHeader: FC<{ {`${beerPost.postedBy.username} `} - - {`${timeDistance} ago`} - + {timeDistance && ( + + {`${timeDistance} ago`} + + )}

{beerPost.description}

@@ -86,15 +81,15 @@ const BeerInfoHeader: FC<{ {beerPost.ibu} IBU
- - Liked by {likeCount} user{likeCount !== 1 && 's'} - + {likeCount && ( + + Liked by {likeCount} user{likeCount !== 1 && 's'} + + )}
- {user && ( - - )} + {user && }
diff --git a/components/BeerById/BeerPostLikeButton.tsx b/components/BeerById/BeerPostLikeButton.tsx index 4ccf7f2..60f6870 100644 --- a/components/BeerById/BeerPostLikeButton.tsx +++ b/components/BeerById/BeerPostLikeButton.tsx @@ -1,12 +1,13 @@ import useCheckIfUserLikesBeerPost from '@/hooks/useCheckIfUserLikesBeerPost'; import sendLikeRequest from '@/requests/sendLikeRequest'; -import { Dispatch, FC, SetStateAction, useState } from 'react'; +import { FC, useState } from 'react'; import { FaThumbsUp, FaRegThumbsUp } from 'react-icons/fa'; +import { KeyedMutator } from 'swr'; const BeerPostLikeButton: FC<{ beerPostId: string; - setLikeCount: Dispatch>; -}> = ({ beerPostId, setLikeCount }) => { + mutateCount: KeyedMutator; +}> = ({ beerPostId, mutateCount }) => { const { isLiked, mutate: mutateLikeStatus } = useCheckIfUserLikesBeerPost(beerPostId); const [loading, setLoading] = useState(false); @@ -14,7 +15,7 @@ const BeerPostLikeButton: FC<{ try { setLoading(true); await sendLikeRequest(beerPostId); - setLikeCount((prevCount) => prevCount + (isLiked ? -1 : 1)); + mutateCount(); mutateLikeStatus(); setLoading(false); } catch (e) { diff --git a/components/BeerById/CommentCardBody.tsx b/components/BeerById/CommentCardBody.tsx index fd27e5b..256f198 100644 --- a/components/BeerById/CommentCardBody.tsx +++ b/components/BeerById/CommentCardBody.tsx @@ -1,8 +1,9 @@ import UserContext from '@/contexts/userContext'; +import useTimeDistance from '@/hooks/useTimeDistance'; import BeerCommentQueryResult from '@/services/BeerComment/schema/BeerCommentQueryResult'; -import { format, formatDistanceStrict } from 'date-fns'; +import { format } from 'date-fns'; import Link from 'next/link'; -import { useContext, useEffect, useState } from 'react'; +import { useContext } from 'react'; import { Rating } from 'react-daisyui'; import { FaEllipsisH } from 'react-icons/fa'; @@ -63,12 +64,9 @@ const CommentCardBody: React.FC<{ pageCount: number; }>; }> = ({ comment, mutate }) => { - const [timeDistance, setTimeDistance] = useState(''); const { user } = useContext(UserContext); - useEffect(() => { - setTimeDistance(formatDistanceStrict(new Date(comment.createdAt), new Date())); - }, [comment.createdAt]); + const timeDistance = useTimeDistance(new Date(comment.createdAt)); return (
diff --git a/components/BeerIndex/BeerIndexPaginationBar.tsx b/components/BeerIndex/BeerIndexPaginationBar.tsx index c90217c..3a8e974 100644 --- a/components/BeerIndex/BeerIndexPaginationBar.tsx +++ b/components/BeerIndex/BeerIndexPaginationBar.tsx @@ -14,7 +14,6 @@ const BeerIndexPaginationBar: FC = ({ pageCount, pageNum }) => className={`btn ${pageNum === 1 ? 'btn-disabled' : ''}`} href={{ pathname: '/beers', query: { page_num: pageNum - 1 } }} scroll={false} - prefetch={true} > « @@ -23,7 +22,6 @@ const BeerIndexPaginationBar: FC = ({ pageCount, pageNum }) => className={`btn ${pageNum === pageCount ? 'btn-disabled' : ''}`} href={{ pathname: '/beers', query: { page_num: pageNum + 1 } }} scroll={false} - prefetch={true} > » diff --git a/hooks/useBeerPostComments.ts b/hooks/useBeerPostComments.ts index c70ba50..3241270 100644 --- a/hooks/useBeerPostComments.ts +++ b/hooks/useBeerPostComments.ts @@ -9,6 +9,17 @@ interface UseBeerPostCommentsProps { pageSize: number; } +/** + * A custom React hook that fetches comments for a specific beer post. + * + * @param props - The props object. + * @param props.pageNum - The page number of the comments to fetch. + * @param props.id - The ID of the beer post to fetch comments for. + * @param props.pageSize - The number of comments to fetch per page. + * @returns An object containing the fetched comments, the total number of comment pages, + * a boolean indicating if the request is currently loading, and a function to mutate + * the data. + */ const useBeerPostComments = ({ pageNum, id, pageSize }: UseBeerPostCommentsProps) => { const { data, error, isLoading, mutate } = useSWR( `/api/beers/${id}/comments?page_num=${pageNum}&page_size=${pageSize}`, diff --git a/hooks/useBeerPostSearch.ts b/hooks/useBeerPostSearch.ts index f135908..3f20ea2 100644 --- a/hooks/useBeerPostSearch.ts +++ b/hooks/useBeerPostSearch.ts @@ -1,7 +1,14 @@ import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import useSWR from 'swr'; import { z } from 'zod'; - +/** + * A custom React hook that searches for beer posts that match a given query string. + * + * @param query The search query string to match beer posts against. + * @returns An object containing an array of search results matching the query, an error + * object if an error occurred during the search, and a boolean indicating if the + * request is currently loading. + */ const useBeerPostSearch = (query: string | undefined) => { const { data, isLoading, error } = useSWR( `/api/beers/search?search=${query}`, diff --git a/hooks/useCheckIfUserLikesBeerPost.ts b/hooks/useCheckIfUserLikesBeerPost.ts index 0e01a57..ce44b6c 100644 --- a/hooks/useCheckIfUserLikesBeerPost.ts +++ b/hooks/useCheckIfUserLikesBeerPost.ts @@ -4,6 +4,17 @@ import { useContext } from 'react'; import useSWR from 'swr'; import { z } from 'zod'; +/** + * A custom React hook that checks if the current user has liked a beer post by fetching + * data from the server. + * + * @param beerPostId The ID of the beer post to check for likes. + * @returns An object containing a boolean indicating if the user has liked the beer post, + * an error object if an error occurred during the request, and a boolean indicating if + * the request is currently loading. + * @throws When the user is not logged in, the server returns an error status code, or if + * the response data fails to validate against the expected schema. + */ const useCheckIfUserLikesBeerPost = (beerPostId: string) => { const { user } = useContext(UserContext); const { data, error, isLoading, mutate } = useSWR( diff --git a/hooks/useGetLikeCount.ts b/hooks/useGetLikeCount.ts new file mode 100644 index 0000000..1c9e514 --- /dev/null +++ b/hooks/useGetLikeCount.ts @@ -0,0 +1,48 @@ +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { z } from 'zod'; +import useSWR from 'swr'; + +/** + * Custom hook to fetch the like count for a beer post from the server. + * + * @param beerPostId - The ID of the beer post to fetch the like count for. + * @returns An object with the current like count, as well as metadata about the current + * state of the request. + */ + +const useGetLikeCount = (beerPostId: string) => { + const { error, mutate, data, isLoading } = useSWR( + `/api/beers/${beerPostId}/like`, + async (url) => { + const response = await fetch(url); + const json = await response.json(); + + const parsed = APIResponseValidationSchema.safeParse(json); + + if (!parsed.success) { + throw new Error('Failed to parse API response'); + } + + const parsedPayload = z + .object({ + likeCount: z.number(), + }) + .safeParse(parsed.data.payload); + + if (!parsedPayload.success) { + throw new Error('Failed to parse API response payload'); + } + + return parsedPayload.data.likeCount; + }, + ); + + return { + error: error as unknown, + isLoading, + mutate, + likeCount: data as number | undefined, + }; +}; + +export default useGetLikeCount; diff --git a/hooks/useRedirectIfLoggedIn.ts b/hooks/useRedirectIfLoggedIn.ts index 6709ddf..d8d444e 100644 --- a/hooks/useRedirectIfLoggedIn.ts +++ b/hooks/useRedirectIfLoggedIn.ts @@ -2,7 +2,14 @@ import UserContext from '@/contexts/userContext'; import { useRouter } from 'next/router'; import { useContext } from 'react'; -const useRedirectWhenLoggedIn = () => { +/** + * Custom React hook that redirects the user to the home page if they are logged in. This + * hook is used to prevent logged in users from accessing the login and signup pages. Must + * be used under the UserContext provider. + * + * @returns {void} + */ +const useRedirectWhenLoggedIn = (): void => { const { user } = useContext(UserContext); const router = useRouter(); diff --git a/hooks/useTimeDistance.ts b/hooks/useTimeDistance.ts new file mode 100644 index 0000000..9d4f01b --- /dev/null +++ b/hooks/useTimeDistance.ts @@ -0,0 +1,20 @@ +import formatDistanceStrict from 'date-fns/formatDistanceStrict'; +import { useState, useEffect } from 'react'; + +/** + * Returns the time distance between the provided date and the current time, using the + * `date-fns` `formatDistanceStrict` function. This hook ensures that the same result is + * calculated on both the server and client, preventing hydration errors. + * + * @param createdAt The date to calculate the time distance from. + * @returns The time distance between the provided date and the current time. + */ +const useTimeDistance = (createdAt: Date) => { + const [timeDistance, setTimeDistance] = useState(''); + useEffect(() => { + setTimeDistance(formatDistanceStrict(createdAt, new Date())); + }, [createdAt]); + return timeDistance; +}; + +export default useTimeDistance; diff --git a/hooks/useUser.ts b/hooks/useUser.ts index e130745..b4891ef 100644 --- a/hooks/useUser.ts +++ b/hooks/useUser.ts @@ -2,6 +2,15 @@ import GetUserSchema from '@/services/User/schema/GetUserSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import useSWR from 'swr'; +/** + * A custom React hook that fetches the current user's data from the server. + * + * @returns An object containing the current user's data, a boolean indicating if the + * request is currently loading, and an error object if an error occurred during the + * request. + * @throws When the user is not logged in, the server returns an error status code, or if + * the response data fails to validate against the expected schema. + */ const useUser = () => { const { data: user, diff --git a/pages/api/beers/[id]/like/index.ts b/pages/api/beers/[id]/like/index.ts index 6fe71ba..a0af1be 100644 --- a/pages/api/beers/[id]/like/index.ts +++ b/pages/api/beers/[id]/like/index.ts @@ -4,13 +4,14 @@ import getBeerPostById from '@/services/BeerPost/getBeerPostById'; import { UserExtendedNextApiRequest } from '@/config/auth/types'; import { createRouter } from 'next-connect'; import { z } from 'zod'; -import { NextApiResponse } from 'next'; +import { NextApiRequest, NextApiResponse } from 'next'; import ServerError from '@/config/util/ServerError'; import createBeerPostLike from '@/services/BeerPostLike/createBeerPostLike'; import removeBeerPostLikeById from '@/services/BeerPostLike/removeBeerPostLikeById'; import findBeerPostLikeById from '@/services/BeerPostLike/findBeerPostLikeById'; import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +import DBClient from '@/prisma/DBClient'; const sendLikeRequest = async ( req: UserExtendedNextApiRequest, @@ -43,6 +44,24 @@ const sendLikeRequest = async ( res.status(200).json(jsonResponse); }; +const getLikeCount = async ( + req: NextApiRequest, + res: NextApiResponse>, +) => { + const id = req.query.id as string; + + const likes = await DBClient.instance.beerPostLike.count({ + where: { beerPostId: id }, + }); + + res.status(200).json({ + success: true, + message: 'Successfully retrieved like count.', + statusCode: 200, + payload: { likeCount: likes }, + }); +}; + const router = createRouter< UserExtendedNextApiRequest, NextApiResponse> @@ -54,5 +73,11 @@ router.post( sendLikeRequest, ); +router.get( + validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }), + getLikeCount, +); + const handler = router.handler(NextConnectOptions); + export default handler; diff --git a/pages/beers/[id]/index.tsx b/pages/beers/[id]/index.tsx index 0292eaa..9fcd191 100644 --- a/pages/beers/[id]/index.tsx +++ b/pages/beers/[id]/index.tsx @@ -12,7 +12,6 @@ import getBeerRecommendations from '@/services/BeerPost/getBeerRecommendations'; import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import { BeerPost } from '@prisma/client'; -import getBeerPostLikeCount from '@/services/BeerPostLike/getBeerPostLikeCount'; import { z } from 'zod'; @@ -22,14 +21,9 @@ interface BeerPageProps { brewery: { id: string; name: string }; beerImages: { id: string; alt: string; url: string }[]; })[]; - likeCount: number; } -const BeerByIdPage: NextPage = ({ - beerPost, - beerRecommendations, - likeCount, -}) => { +const BeerByIdPage: NextPage = ({ beerPost, beerRecommendations }) => { return ( @@ -49,7 +43,7 @@ const BeerByIdPage: NextPage = ({
- +
@@ -73,12 +67,9 @@ export const getServerSideProps: GetServerSideProps = async (cont const { type, brewery, id } = beerPost; const beerRecommendations = await getBeerRecommendations({ type, brewery, id }); - const likeCount = await getBeerPostLikeCount(beerPost.id); - const props = { beerPost: JSON.parse(JSON.stringify(beerPost)), beerRecommendations: JSON.parse(JSON.stringify(beerRecommendations)), - likeCount: JSON.parse(JSON.stringify(likeCount)), }; return { props };