From e5ce2c62c21b85d581b41a29279d968022682e82 Mon Sep 17 00:00:00 2001 From: amalv <1252707+amalv@users.noreply.github.com> Date: Thu, 21 Dec 2023 23:07:50 +0100 Subject: [PATCH] feat: implement infinite scrolling in Books component - Add useBooks hook to manage fetching and state for infinite scrolling - Update Books component to use useBooks hook - Update BookList and App tests to accommodate changes --- src/App.test.tsx | 13 ++- src/components/BookList/BookList.test.tsx | 99 +++++++------------ .../BookList/components/Books/Books.tsx | 14 ++- .../BookList/components/Books/useBooks.ts | 80 +++++++++++++++ 4 files changed, 133 insertions(+), 73 deletions(-) create mode 100644 src/components/BookList/components/Books/useBooks.ts diff --git a/src/App.test.tsx b/src/App.test.tsx index 73e9245..67c6d38 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,9 +1,18 @@ -import { describe, it } from "vitest"; -import { render } from "@testing-library/react"; +import { describe, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; import App from "./App"; +const mockBookList = vi.fn(); +vi.mock("./components/BookList/BookList", () => ({ + BookList: () => { + mockBookList(); + return
BookList
; + }, +})); + describe("App", () => { it("renders without errors", () => { render(); + expect(screen.getByText("BookList")).toBeInTheDocument(); }); }); diff --git a/src/components/BookList/BookList.test.tsx b/src/components/BookList/BookList.test.tsx index da2c1b5..23e62a6 100644 --- a/src/components/BookList/BookList.test.tsx +++ b/src/components/BookList/BookList.test.tsx @@ -1,73 +1,46 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { MockedProvider } from "@apollo/client/testing"; -import { describe, expect, it } from "vitest"; -import { BOOKS_QUERY } from "../../data/books"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import { BookList } from "./BookList"; +import { BooksProps } from "./components"; -const mocks = [ - { - request: { - query: BOOKS_QUERY, - variables: { title: "", limit: 50 }, - }, - result: { - data: { - books: { - cursor: "3", - books: [ - { - id: "1", - title: "1984", - author: "George Orwell", - publicationDate: "1949-06-08", - image: null, - rating: 85, - ratingsCount: 123, - __typename: "Book", - }, - { - id: "2", - title: "The Great Gatsby", - author: "F. Scott Fitzgerald", - publicationDate: "1925-04-10", - image: null, - rating: 88, - ratingsCount: 200, - __typename: "Book", - }, - { - id: "3", - title: "To Kill a Mockingbird", - author: "Harper Lee", - publicationDate: "1960-07-11", - image: null, - rating: 90, - ratingsCount: 250, - __typename: "Book", - }, - ], - }, - }, - }, +const mockBooks = vi.fn(); +vi.mock("./components/Books/Books", () => ({ + Books: (props: BooksProps) => { + mockBooks(props); + return
Books
; }, -]; +})); describe("BookList", () => { - it("renders a list of books", async () => { - render( - - - - ); + it("renders correctly", () => { + render(); + + expect(screen.getByText("Books")).toBeInTheDocument(); - const expectedTitles = mocks[0].result.data.books.books.map( - (book) => book.title + expect(mockBooks).toHaveBeenCalledWith( + expect.objectContaining({ + title: "", // Initial debouncedSearch value + limit: 50, + }) ); + }); + + it("updates search state correctly", async () => { + render(); - for (const title of expectedTitles) { - await waitFor(() => { - expect(screen.getByText(title)).toBeInTheDocument(); - }); - } + // Simulate user input to the SearchInput component + fireEvent.change(screen.getByLabelText("Search by title"), { + target: { value: "New search value" }, + }); + + // Wait for the debounce delay + await act(() => new Promise((resolve) => setTimeout(resolve, 500))); + + // Check that the Books component was called with the updated debouncedSearch value + expect(mockBooks).toHaveBeenCalledWith( + expect.objectContaining({ + title: "New search value", + }) + ); }); }); diff --git a/src/components/BookList/components/Books/Books.tsx b/src/components/BookList/components/Books/Books.tsx index 1f89ad9..207e67c 100644 --- a/src/components/BookList/components/Books/Books.tsx +++ b/src/components/BookList/components/Books/Books.tsx @@ -1,11 +1,9 @@ -// components/BookList/components/Books/Books.tsx - -import { useQuery } from "@apollo/client"; import { Box, Grid } from "@mui/material"; -import { BOOKS_QUERY, Book } from "../../../../data/books"; +import { Book } from "../../../../data/books"; import { BookCard } from "./components"; +import { useBooks } from "./useBooks"; -interface BooksProps { +export interface BooksProps { title: string; limit: number; } @@ -24,9 +22,7 @@ const Message = ({ text }: { text: string }) => ( ); export const Books = ({ title, limit }: BooksProps) => { - const { loading, error, data } = useQuery(BOOKS_QUERY, { - variables: { title, limit }, - }); + const { loading, error, data, loader } = useBooks({ title, limit }); if (error) { console.error("Failed to fetch books:", error); @@ -47,8 +43,10 @@ export const Books = ({ title, limit }: BooksProps) => { ) : ( )} +
+ ); }; diff --git a/src/components/BookList/components/Books/useBooks.ts b/src/components/BookList/components/Books/useBooks.ts new file mode 100644 index 0000000..7d7780c --- /dev/null +++ b/src/components/BookList/components/Books/useBooks.ts @@ -0,0 +1,80 @@ +import { useQuery } from "@apollo/client"; +import { useEffect, useRef, useCallback, useState } from "react"; +import { BOOKS_QUERY } from "../../../../data/books"; + +interface UseBooksProps { + title: string; + limit: number; +} + +export const useBooks = ({ title, limit }: UseBooksProps) => { + const lastPageReachedRef = useRef(false); + const [lastPageReached, setLastPageReached] = useState(false); + const { loading, error, data, fetchMore } = useQuery(BOOKS_QUERY, { + variables: { title, limit, cursor: "0" }, + }); + + const loader = useRef(null); + + useEffect(() => { + lastPageReachedRef.current = lastPageReached; + }, [lastPageReached]); + + const handleObserver = useCallback( + (entities: IntersectionObserverEntry[], observer: IntersectionObserver) => { + const target = entities[0]; + if (target.isIntersecting && !loading && !lastPageReachedRef.current) { + observer.unobserve(target.target); + + fetchMore({ + variables: { + cursor: data?.books?.cursor, + }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult) return prev; + const isLastPage = fetchMoreResult.books.books.length < 50; + setLastPageReached(isLastPage); + return { + books: { + __typename: prev.books.__typename, + cursor: fetchMoreResult.books.cursor, + books: [...prev.books.books, ...fetchMoreResult.books.books], + }, + }; + }, + }).then(() => { + if (loader.current && observer && !lastPageReachedRef.current) { + observer.observe(loader.current); + } + }); + } + }, + [data, fetchMore, loading] + ); + + useEffect(() => { + const options = { + root: null, + rootMargin: "20px", + threshold: 1.0, + }; + + const observer = new IntersectionObserver((entries) => { + if (observer) { + handleObserver(entries, observer); + } + }, options); + + if (loader.current) { + observer.observe(loader.current); + } + + return () => { + if (loader.current) { + observer.unobserve(loader.current); + } + }; + }, [handleObserver]); + + return { loading, error, data, loader }; +};