-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
4 changed files
with
133 additions
and
73 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
}) | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
}; |