Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions frontend/app/hooks/useSuggestedBooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { BookFilters } from "~/api/types";
import { useSearchBooks } from "~/hooks/useSearchBooks";
import { BOOK_PAGE_LIMIT } from "~/lib/constants";

const MIN_SUGGESTION_RATINGS = 10_000;

function useSuggestedBooks({
query,
filters,
enabled,
}: {
query: string;
filters: BookFilters;
enabled: boolean;
}) {
const keptFilters = query ? filters : {};

return useSearchBooks({
filters: {
...keptFilters,
minRatings: Math.max(keptFilters.minRatings ?? 0, MIN_SUGGESTION_RATINGS),
},
title: "",
limit: BOOK_PAGE_LIMIT,
offset: 0,
sort: "rating_desc",
enabled,
});
}

export { useSuggestedBooks };
16 changes: 14 additions & 2 deletions frontend/app/routes/book/selectedBook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useSearchBooks } from "~/hooks/useSearchBooks";
import { useBookSearchParams } from "~/hooks/useBookSearchParams";
import { cn } from "~/lib/utils";
import { useFavoriteBooks } from "~/hooks/useFavoriteBooks";
import { useSuggestedBooks } from "~/hooks/useSuggestedBooks";
import { BookRating } from "~/components/BookRating/BookRating";
import { BOOK_PAGE_LIMIT } from "~/lib/constants";

Expand All @@ -37,15 +38,26 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) {
limit: BOOK_PAGE_LIMIT,
offset: (page - 1) * BOOK_PAGE_LIMIT,
sort,
enabled: origin !== "favorites",
enabled: origin !== "favorites" && origin !== "suggestions",
});

const { books: favoriteBooks } = useFavoriteBooks({
page,
enabled: origin === "favorites",
});

const books = origin === "favorites" ? favoriteBooks : searchBooks;
const { books: suggestedBooks } = useSuggestedBooks({
query,
filters,
enabled: origin === "suggestions",
});

const books =
origin === "favorites"
? favoriteBooks
: origin === "suggestions"
? suggestedBooks
: searchBooks;

if (loading) {
return (
Expand Down
140 changes: 139 additions & 1 deletion frontend/app/routes/books.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,20 @@ describe("BooksPage", () => {
it("tells the user when no books match the search", async () => {
renderRoutes(routes, {
initialEntries: ["/books?q=zzzz"],
mocks: [searchBooksMock({ title: "zzzz", limit: 12, offset: 0 }, [])],
mocks: [
searchBooksMock({ title: "zzzz", limit: 12, offset: 0 }, []),
searchBooksMock(
{
title: "",
minRatings: 10000,
sortBy: "RATING",
sortDirection: "DESC",
limit: 12,
offset: 0,
},
[],
),
],
});

expect(
Expand All @@ -88,6 +101,131 @@ describe("BooksPage", () => {
expect(screen.getByText("0 results")).toBeInTheDocument();
});

it("suggests top-rated books with the same filters when the search has no results", async () => {
renderRoutes(routes, {
initialEntries: ["/books?q=zzzz&genres=Fantasy"],
mocks: [
searchBooksMock(
{ title: "zzzz", genres: ["Fantasy"], limit: 12, offset: 0 },
[],
),
searchBooksMock({
title: "",
genres: ["Fantasy"],
minRatings: 10000,
sortBy: "RATING",
sortDirection: "DESC",
limit: 12,
offset: 0,
}),
],
});

expect(
await screen.findByRole("heading", {
name: "Top-rated books matching your filters",
}),
).toBeInTheDocument();
expect(
screen.getByText("No books found for this search"),
).toBeInTheDocument();
expect(await findBookLinks()).toHaveLength(3);
});

it("suggests top-rated books without filters when the filters have no results", async () => {
renderRoutes(routes, {
initialEntries: ["/books?genres=Fantasy&maxPages=1"],
mocks: [
searchBooksMock(
{
title: "",
genres: ["Fantasy"],
maxPages: 1,
limit: 12,
offset: 0,
},
[],
),
searchBooksMock({
title: "",
minRatings: 10000,
sortBy: "RATING",
sortDirection: "DESC",
limit: 12,
offset: 0,
}),
],
});

expect(
await screen.findByRole("heading", { name: "Top-rated books" }),
).toBeInTheDocument();
expect(await findBookLinks()).toHaveLength(3);
});

it("keeps the user's minimum number of ratings in the suggestions when it is higher", async () => {
renderRoutes(routes, {
initialEntries: ["/books?q=zzzz&minRatings=50000"],
mocks: [
searchBooksMock(
{ title: "zzzz", minRatings: 50000, limit: 12, offset: 0 },
[],
),
searchBooksMock({
title: "",
minRatings: 50000,
sortBy: "RATING",
sortDirection: "DESC",
limit: 12,
offset: 0,
}),
],
});

expect(await findBookLinks()).toHaveLength(3);
});

it("browses through the suggestions on a suggested book's details page", async () => {
const user = userEvent.setup();
renderRoutes(routes, {
initialEntries: ["/books?q=zzzz"],
// The suggestions are reused from Apollo's cache on the details page.
mocks: [
searchBooksMock({ title: "zzzz", limit: 12, offset: 0 }, []),
searchBooksMock({
title: "",
minRatings: 10000,
sortBy: "RATING",
sortDirection: "DESC",
limit: 12,
offset: 0,
}),
bookMock(2, {
...apiBook,
id: 2,
title: "Before They Are Hanged",
description: "The second book in The First Law trilogy.",
}),
],
});

await user.click(
await screen.findByRole("link", {
name: "View details for Before They Are Hanged",
}),
);

expect(
await screen.findByText("The second book in The First Law trilogy."),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Go to previous book" }),
).not.toHaveAttribute("aria-disabled", "true");
expect(
screen.getByRole("link", { name: "Go to next book" }),
).not.toHaveAttribute("aria-disabled", "true");
});

it("shows an error message when the API request fails", async () => {
renderRoutes(routes, {
initialEntries: ["/books"],
Expand Down
79 changes: 70 additions & 9 deletions frontend/app/routes/books.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { BookPagination } from "~/components/BookPagination/BookPagination";
import { useSearchBooks } from "~/hooks/useSearchBooks";
import { useSearchBooks, type SearchBook } from "~/hooks/useSearchBooks";
import { SearchBar } from "~/components/books/SearchBar/SearchBar";
import { BookCard } from "~/components/BookCard/BookCard";
import { SortSelect } from "~/components/books/SortSelect/SortSelect";
import { useBookSearchParams } from "~/hooks/useBookSearchParams";
import { useSuggestedBooks } from "~/hooks/useSuggestedBooks";
import { useSearchParams } from "react-router";
import { BOOK_PAGE_LIMIT } from "~/lib/constants";

export function meta() {
Expand All @@ -13,7 +15,26 @@ export function meta() {
];
}

function BookList({
books,
searchListParams,
}: {
books: SearchBook[];
searchListParams?: string;
}) {
return (
<ul className="grid grid-cols-4 justify-center gap-4 mx-auto px-10 pb-10 pt-4 max-w-300 max-[1200px]:grid-cols-3 max-[1000px]:grid-cols-2 max-[600px]:grid-cols-1">
{books.map((book) => (
<li key={book.id}>
<BookCard book={book} searchListParams={searchListParams} />
</li>
))}
</ul>
);
}

export default function BooksPage() {
const [searchParams] = useSearchParams();
const { query, page, filters, sort } = useBookSearchParams();

const { books, totalCount, loading, error } = useSearchBooks({
Expand All @@ -24,6 +45,21 @@ export default function BooksPage() {
sort,
});

const hasFilters = Object.values(filters).some(
(value) => value !== undefined,
);

const { books: suggestions } = useSuggestedBooks({
query,
filters,
enabled: !loading && !error && totalCount === 0,
});

// If we get no results, we set this so the book details page uses the suggestions
// and the previous/next buttons work as expected.
const suggestionParams = new URLSearchParams(searchParams);
suggestionParams.set("origin", "suggestions");

if (loading) {
return (
<main>
Expand Down Expand Up @@ -53,12 +89,43 @@ export default function BooksPage() {
}

if (!books || books.length === 0) {
if (suggestions && suggestions.length > 0) {
return (
<main>
<SearchBar resultCount={0} />
<section
aria-labelledby="suggestions-heading"
className="flex flex-col gap-2 pt-10"
>
<p role="status" className="px-10 text-center text-destructive">
No books found for this search
</p>
<p className="px-10 text-center text-muted-foreground">
In the meantime, here are some popular books you might like
</p>
<h2
id="suggestions-heading"
className="px-10 text-center text-xl font-bold"
>
{query && hasFilters
? "Top-rated books matching your filters"
: "Top-rated books"}
</h2>
<BookList
books={suggestions}
searchListParams={suggestionParams.toString()}
/>
</section>
</main>
);
}

return (
<main>
<SearchBar resultCount={0} />
<p
role="status"
className="grid place-items-center min-h-[50svh] px-10 text-center"
className="grid place-items-center min-h-[50svh] px-10 text-center text-destructive"
>
No books found for this search
</p>
Expand All @@ -71,13 +138,7 @@ export default function BooksPage() {
<SearchBar resultCount={totalCount} />

<SortSelect />
<ul className="grid grid-cols-4 justify-center gap-4 mx-auto px-10 pb-10 pt-4 max-w-300 max-[1200px]:grid-cols-3 max-[1000px]:grid-cols-2 max-[600px]:grid-cols-1">
{books.map((book) => (
<li key={book.id}>
<BookCard book={book} />
</li>
))}
</ul>
<BookList books={books} />
<BookPagination totalPages={Math.ceil(totalCount / BOOK_PAGE_LIMIT)} />
</main>
);
Expand Down