Skip to content

Commit

Permalink
feat: implement infinite scrolling in Books component
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
amalv committed Dec 21, 2023
1 parent 67ea473 commit e5ce2c6
Show file tree
Hide file tree
Showing 4 changed files with 133 additions and 73 deletions.
13 changes: 11 additions & 2 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>BookList</div>;
},
}));

describe("App", () => {
it("renders without errors", () => {
render(<App />);
expect(screen.getByText("BookList")).toBeInTheDocument();
});
});
99 changes: 36 additions & 63 deletions src/components/BookList/BookList.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>Books</div>;
},
];
}));

describe("BookList", () => {
it("renders a list of books", async () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<BookList />
</MockedProvider>
);
it("renders correctly", () => {
render(<BookList />);

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(<BookList />);

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",
})
);
});
});
14 changes: 6 additions & 8 deletions src/components/BookList/components/Books/Books.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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);
Expand All @@ -47,8 +43,10 @@ export const Books = ({ title, limit }: BooksProps) => {
) : (
<Message text="No books available" />
)}
<div ref={loader} />
</Grid>
</Grid>
<Grid item xs={1} sm={1} md={2} />
</Grid>
);
};
80 changes: 80 additions & 0 deletions src/components/BookList/components/Books/useBooks.ts
Original file line number Diff line number Diff line change
@@ -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 };
};

0 comments on commit e5ce2c6

Please sign in to comment.