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 };
+};