From 915adb722a23ca9e9410120996559383eb6037c5 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sun, 9 Apr 2023 18:41:58 -0400 Subject: [PATCH] Implement react-intersection-observer to facilitate infinite scroll Uses react-intersection-observer to load more comments when the last of the previously loaded comments is in the viewport. --- components/BeerById/BeerCommentForm.tsx | 13 +-- .../BeerById/BeerPostCommentsSection.tsx | 71 +++++++++++---- components/BeerById/BeerPostLikeButton.tsx | 6 +- components/BeerById/CommentCardBody.tsx | 91 +++++++++---------- .../BeerById/CommentLoadingCardBody.tsx | 6 +- components/ui/Spinner.tsx | 4 +- contexts/userContext.ts | 4 +- hooks/useBeerPostComments.ts | 47 +++++----- package-lock.json | 15 +++ package.json | 1 + prisma/seed/create/createNewUsers.ts | 2 +- prisma/seed/index.ts | 6 +- 12 files changed, 157 insertions(+), 109 deletions(-) diff --git a/components/BeerById/BeerCommentForm.tsx b/components/BeerById/BeerCommentForm.tsx index 780ceb1..0de427f 100644 --- a/components/BeerById/BeerCommentForm.tsx +++ b/components/BeerById/BeerCommentForm.tsx @@ -8,7 +8,6 @@ import { Rating } from 'react-daisyui'; import { useForm, SubmitHandler } from 'react-hook-form'; import { z } from 'zod'; - import { useRouter } from 'next/router'; import useBeerPostComments from '@/hooks/useBeerPostComments'; import Button from '../ui/forms/Button'; @@ -18,10 +17,9 @@ import FormLabel from '../ui/forms/FormLabel'; import FormSegment from '../ui/forms/FormSegment'; import FormTextArea from '../ui/forms/FormTextArea'; - interface BeerCommentFormProps { beerPost: z.infer; - mutate: ReturnType['mutate'] + mutate: ReturnType['mutate']; } const BeerCommentForm: FunctionComponent = ({ @@ -43,7 +41,6 @@ const BeerCommentForm: FunctionComponent = ({ reset({ rating: 0, content: '' }); }, [reset]); - const router = useRouter(); const onSubmit: SubmitHandler> = async ( data, ) => { @@ -54,14 +51,8 @@ const BeerCommentForm: FunctionComponent = ({ rating: data.rating, beerPostId: beerPost.id, }); + await mutate(); reset(); - - const submitTasks: Promise[] = [ - router.push(`/beers/${beerPost.id}`, undefined, { scroll: false }), - mutate(), - ]; - - await Promise.all(submitTasks); }; const { errors } = formState; diff --git a/components/BeerById/BeerPostCommentsSection.tsx b/components/BeerById/BeerPostCommentsSection.tsx index a60559e..bfeb7c9 100644 --- a/components/BeerById/BeerPostCommentsSection.tsx +++ b/components/BeerById/BeerPostCommentsSection.tsx @@ -3,20 +3,35 @@ import UserContext from '@/contexts/userContext'; import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; -import { FC, useContext } from 'react'; +import { FC, MutableRefObject, useContext, useRef } from 'react'; import { z } from 'zod'; import useBeerPostComments from '@/hooks/useBeerPostComments'; import { useRouter } from 'next/router'; +import { useInView } from 'react-intersection-observer'; import BeerCommentForm from './BeerCommentForm'; import CommentCardBody from './CommentCardBody'; import NoCommentsCard from './NoCommentsCard'; import CommentLoadingCardBody from './CommentLoadingCardBody'; +import Spinner from '../ui/Spinner'; interface BeerPostCommentsSectionProps { beerPost: z.infer; } +const LoadingComponent: FC<{ length: number }> = ({ length }) => { + return ( + <> + {Array.from({ length }).map((_, i) => ( + + ))} +
+ +
+ + ); +}; + const BeerPostCommentsSection: FC = ({ beerPost }) => { const { user } = useContext(UserContext); const router = useRouter(); @@ -24,13 +39,22 @@ const BeerPostCommentsSection: FC = ({ beerPost }) const pageNum = parseInt(router.query.comments_page as string, 10) || 1; const PAGE_SIZE = 6; - const { comments, isLoading, mutate, setSize, size, isLoadingMore } = + const { comments, isLoading, mutate, setSize, size, isLoadingMore, isAtEnd } = useBeerPostComments({ id, pageNum, pageSize: PAGE_SIZE, }); + const { ref } = useInView({ + delay: 3000, + onChange: (visible) => { + if (!visible || isAtEnd) return; + setSize(size + 1); + }, + }); + + const sectionRef: MutableRefObject = useRef(null); return (
@@ -46,19 +70,36 @@ const BeerPostCommentsSection: FC = ({ beerPost })
{comments && !!comments.length && !isLoading && ( -
- {comments.map((comment) => ( - - ))} +
+ {comments.map((comment, index) => { + const isLastComment = index === comments.length - 1; - {isLoadingMore && - Array.from({ length: PAGE_SIZE }).map((_, i) => ( - - ))} + return ( +
+ +
+ ); + })} - + {!!isLoadingMore && ( +
+ +
+ )} + + {isAtEnd && ( +
+ +
+ )}
)} @@ -66,9 +107,7 @@ const BeerPostCommentsSection: FC = ({ beerPost }) {isLoading && (
- {Array.from({ length: PAGE_SIZE }).map((_, i) => ( - - ))} +
)}
diff --git a/components/BeerById/BeerPostLikeButton.tsx b/components/BeerById/BeerPostLikeButton.tsx index 68e4285..bc54a92 100644 --- a/components/BeerById/BeerPostLikeButton.tsx +++ b/components/BeerById/BeerPostLikeButton.tsx @@ -3,7 +3,6 @@ import sendLikeRequest from '@/requests/sendLikeRequest'; import { FC, useEffect, useState } from 'react'; import { FaThumbsUp, FaRegThumbsUp } from 'react-icons/fa'; - import useGetLikeCount from '@/hooks/useGetLikeCount'; const BeerPostLikeButton: FC<{ @@ -32,8 +31,9 @@ const BeerPostLikeButton: FC<{ return ( - ) : ( - - )} @@ -59,52 +55,51 @@ const CommentCardDropdown: FC = ({ comment, mutate }) => { ); }; -const CommentCardBody: FC - = ({ comment, mutate }) => { - const { user } = useContext(UserContext); +const CommentCardBody: FC = ({ comment, mutate, ref }) => { + const { user } = useContext(UserContext); - const timeDistance = useTimeDistance(new Date(comment.createdAt)); + const timeDistance = useTimeDistance(new Date(comment.createdAt)); - return ( -
-
-
-

- - {comment.postedBy.username} - -

-

- posted{' '} - {' '} - ago -

-
- - {user && } + return ( +
+
+
+

+ + {comment.postedBy.username} + +

+

+ posted{' '} + {' '} + ago +

-
- - {Array.from({ length: 5 }).map((val, index) => ( - - ))} - -

{comment.content}

-
+ {user && }
- ); - }; + +
+ + {Array.from({ length: 5 }).map((val, index) => ( + + ))} + +

{comment.content}

+
+
+ ); +}; export default CommentCardBody; diff --git a/components/BeerById/CommentLoadingCardBody.tsx b/components/BeerById/CommentLoadingCardBody.tsx index 2d0e292..b662594 100644 --- a/components/BeerById/CommentLoadingCardBody.tsx +++ b/components/BeerById/CommentLoadingCardBody.tsx @@ -1,12 +1,14 @@ const CommentLoadingCardBody = () => { return ( -
+
-
+
+
+
diff --git a/components/ui/Spinner.tsx b/components/ui/Spinner.tsx index 5baaec3..df69177 100644 --- a/components/ui/Spinner.tsx +++ b/components/ui/Spinner.tsx @@ -6,8 +6,8 @@ interface SpinnerProps { const Spinner: FC = ({ size = 'md' }) => { const spinnerWidths: Record, `w-[${number}px]`> = { - xs: 'w-[10px]', - sm: 'w-[20px]', + xs: 'w-[45px]', + sm: 'w-[60px]', md: 'w-[100px]', lg: 'w-[150px]', xl: 'w-[200px]', diff --git a/contexts/userContext.ts b/contexts/userContext.ts index cc6a1f6..7345769 100644 --- a/contexts/userContext.ts +++ b/contexts/userContext.ts @@ -1,4 +1,4 @@ -import useUser from '@/hooks/useUser'; +import useUser from '@/hooks/useUser'; import GetUserSchema from '@/services/User/schema/GetUserSchema'; import { createContext } from 'react'; import { z } from 'zod'; @@ -7,7 +7,7 @@ const UserContext = createContext<{ user?: z.infer; error?: unknown; isLoading: boolean; - mutate?: ReturnType['mutate'] + mutate?: ReturnType['mutate']; }>({ isLoading: true }); export default UserContext; diff --git a/hooks/useBeerPostComments.ts b/hooks/useBeerPostComments.ts index 690eef6..3a75e56 100644 --- a/hooks/useBeerPostComments.ts +++ b/hooks/useBeerPostComments.ts @@ -21,36 +21,39 @@ interface UseBeerPostCommentsProps { * the data. */ const useBeerPostComments = ({ id, pageSize }: UseBeerPostCommentsProps) => { + const fetcher = async (url: string) => { + const response = await fetch(url); + const json = await response.json(); + const count = response.headers.get('X-Total-Count'); + const parsed = APIResponseValidationSchema.safeParse(json); + + if (!parsed.success) { + throw new Error(parsed.error.message); + } + const parsedPayload = z.array(BeerCommentQueryResult).safeParse(parsed.data.payload); + + if (!parsedPayload.success) { + throw new Error(parsedPayload.error.message); + } + + const pageCount = Math.ceil(parseInt(count as string, 10) / pageSize); + return { comments: parsedPayload.data, pageCount }; + }; + const { data, error, isLoading, mutate, size, setSize } = useSWRInfinite( (index) => `/api/beers/${id}/comments?page_num=${index + 1}&page_size=${pageSize}`, - async (url) => { - const response = await fetch(url); - const json = await response.json(); - const count = response.headers.get('X-Total-Count'); - const parsed = APIResponseValidationSchema.safeParse(json); - - if (!parsed.success) { - throw new Error(parsed.error.message); - } - const parsedPayload = z - - .array(BeerCommentQueryResult) - .safeParse(parsed.data.payload); - - if (!parsedPayload.success) { - throw new Error(parsedPayload.error.message); - } - - const pageCount = Math.ceil(parseInt(count as string, 10) / pageSize); - return { comments: parsedPayload.data, pageCount }; - }, + fetcher, + { parallel: true }, ); const comments = data?.flatMap((d) => d.comments) ?? []; + const pageCount = data?.[0].pageCount ?? 0; const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === 'undefined'); + const isAtEnd = !(size < data?.[0].pageCount!); + return { comments, isLoading, @@ -59,6 +62,8 @@ const useBeerPostComments = ({ id, pageSize }: UseBeerPostCommentsProps) => { size, setSize, isLoadingMore, + isAtEnd, + pageCount, }; }; diff --git a/package-lock.json b/package-lock.json index 4137c48..c29ccf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "react-email": "^1.9.0", "react-hook-form": "^7.43.9", "react-icons": "^4.8.0", + "react-intersection-observer": "^9.4.3", "sparkpost": "^2.1.4", "swr": "^2.1.2", "zod": "^3.21.4" @@ -8341,6 +8342,14 @@ "react": "*" } }, + "node_modules/react-intersection-observer": { + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.3.tgz", + "integrity": "sha512-WNRqMQvKpupr6MzecAQI0Pj0+JQong307knLP4g/nBex7kYfIaZsPpXaIhKHR+oV8z+goUbH9e10j6lGRnTzlQ==", + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -15901,6 +15910,12 @@ "integrity": "sha512-N6+kOLcihDiAnj5Czu637waJqSnwlMNROzVZMhfX68V/9bu9qHaMIJC4UdozWoOk57gahFCNHwVvWzm0MTzRjg==", "requires": {} }, + "react-intersection-observer": { + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.3.tgz", + "integrity": "sha512-WNRqMQvKpupr6MzecAQI0Pj0+JQong307knLP4g/nBex7kYfIaZsPpXaIhKHR+oV8z+goUbH9e10j6lGRnTzlQ==", + "requires": {} + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 5efb564..61d2955 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "react-email": "^1.9.0", "react-hook-form": "^7.43.9", "react-icons": "^4.8.0", + "react-intersection-observer": "^9.4.3", "sparkpost": "^2.1.4", "swr": "^2.1.2", "zod": "^3.21.4" diff --git a/prisma/seed/create/createNewUsers.ts b/prisma/seed/create/createNewUsers.ts index 9af1101..c34bf4e 100644 --- a/prisma/seed/create/createNewUsers.ts +++ b/prisma/seed/create/createNewUsers.ts @@ -18,7 +18,7 @@ const createNewUsers = async ({ numberOfUsers }: CreateNewUsersArgs) => { // eslint-disable-next-line no-plusplus for (let i = 0; i < numberOfUsers; i++) { - const randomValue = crypto.randomBytes(4).toString('hex'); + const randomValue = crypto.randomBytes(8).toString('hex'); const firstName = faker.name.firstName(); const lastName = faker.name.lastName(); const username = `${firstName[0]}.${lastName}.${randomValue}`; diff --git a/prisma/seed/index.ts b/prisma/seed/index.ts index d6b52a1..6d4ae14 100644 --- a/prisma/seed/index.ts +++ b/prisma/seed/index.ts @@ -29,7 +29,7 @@ import createNewUsers from './create/createNewUsers'; createNewBeerTypes({ joinData: { users } }), ]); const beerPosts = await createNewBeerPosts({ - numberOfPosts: 48, + numberOfPosts: 200, joinData: { breweryPosts, beerTypes, users }, }); @@ -41,11 +41,11 @@ import createNewUsers from './create/createNewUsers'; breweryImages, ] = await Promise.all([ createNewBeerPostComments({ - numberOfComments: 1000, + numberOfComments: 45000, joinData: { beerPosts, users }, }), createNewBreweryPostComments({ - numberOfComments: 1000, + numberOfComments: 45000, joinData: { breweryPosts, users }, }), createNewBeerPostLikes({