diff --git a/backend/codegen.ts b/backend/codegen.ts index 1ffe5c6..529b86d 100644 --- a/backend/codegen.ts +++ b/backend/codegen.ts @@ -12,6 +12,7 @@ const config: CodegenConfig = { }, }), }, + hooks: { afterAllFileWrite: ["prettier --write"] }, }; export default config; diff --git a/backend/src/db/schema/books.ts b/backend/src/db/schema/books.ts index 99b66d7..d145da4 100644 --- a/backend/src/db/schema/books.ts +++ b/backend/src/db/schema/books.ts @@ -14,7 +14,7 @@ const books = pgTable("books", { author: text("author").notNull(), description: text("description"), language: text("language"), - bookFormat: text("book_format").notNull(), + bookFormat: text("book_format"), edition: text("edition"), pages: integer("pages").notNull(), publisher: text("publisher"), @@ -24,7 +24,7 @@ const books = pgTable("books", { const bookGenres = pgTable("book_genres", { id: integer().primaryKey().generatedAlwaysAsIdentity(), - genre: text("genre").notNull(), + genre: text("genre").unique().notNull(), }); const bookToGenres = pgTable( diff --git a/backend/src/db/seed/index.ts b/backend/src/db/seed/index.ts index a095323..08ac4f7 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -3,7 +3,12 @@ import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { parse } from "csv-parse"; import { db } from "~/db"; -import { books, type InsertBook } from "~/db/schema/books"; +import { + bookGenres, + books, + bookToGenres, + type InsertBook, +} from "~/db/schema/books"; type BookRow = { bookId: string; @@ -14,9 +19,9 @@ type BookRow = { description: string | null; language: string | null; isbn: string; - genres: string[]; + genres: string; characters: string[]; - bookFormat: string; + bookFormat: string | null; edition: string | null; pages: number | string; publisher: string | null; @@ -45,6 +50,43 @@ const parser = createReadStream(resolve(__dirname, "book-data.csv")).pipe( let inserted = 0; let booksToInsert: InsertBook[] = []; +let genresToInsert: string[][] = []; +const genreIds = new Map(); + +async function insertBatch(batch: InsertBook[], batchGenres: string[][]) { + const insertedBooks = await db + .insert(books) + .values(batch) + .returning({ id: books.id }); + + const newGenres = [...new Set(batchGenres.flat())].filter( + (genre) => !genreIds.has(genre), + ); + + if (newGenres.length > 0) { + const insertedGenres = await db + .insert(bookGenres) + .values(newGenres.map((genre) => ({ genre }))) + .returning(); + + insertedGenres.forEach(({ id, genre }) => genreIds.set(genre, id)); + } + + const bookToGenreRows = insertedBooks.flatMap(({ id: bookId }, i) => + [...new Set(batchGenres[i])].map((genre) => { + const genreId = genreIds.get(genre); + + if (genreId === undefined) { + throw new Error(`Genre ID not found for genre "${genre}"`); + } + return { bookId, genreId }; + }), + ); + + if (bookToGenreRows.length > 0) { + await db.insert(bookToGenres).values(bookToGenreRows); + } +} for await (const book of parser as AsyncIterable) { if (typeof book.pages === "string") { @@ -58,22 +100,29 @@ for await (const book of parser as AsyncIterable) { booksToInsert.push({ isbn: book.isbn, title: book.title, - series: book.series || null, + series: book.series?.trim() || null, author: book.author, - description: book.description || null, - language: book.language || null, - bookFormat: book.bookFormat, - edition: book.edition || null, + description: book.description?.trim() || null, + language: book.language?.trim() || null, + bookFormat: book.bookFormat?.trim() || null, + edition: book.edition?.trim() || null, pages: book.pages, - publisher: book.publisher || null, - publishDate: book.publishDate || null, - coverImgUrl: book.coverImg || null, + publisher: book.publisher?.trim() || null, + publishDate: book.publishDate?.trim() || null, + coverImgUrl: book.coverImg?.trim() || null, }); + if (book.genres == "[]") { + genresToInsert.push([]); + } else { + genresToInsert.push(book.genres.slice(2, -2).split("', '")); + } + try { if (booksToInsert.length >= BATCH_SIZE) { - await db.insert(books).values(booksToInsert); + await insertBatch(booksToInsert, genresToInsert); booksToInsert = []; + genresToInsert = []; inserted += BATCH_SIZE; } } catch (error) { @@ -91,7 +140,7 @@ for await (const book of parser as AsyncIterable) { } // Insert last batch if there are any remaining books to insert -await db.insert(books).values(booksToInsert); +await insertBatch(booksToInsert, genresToInsert); console.clear(); console.log("Seeding complete!"); diff --git a/backend/src/schema/books/resolvers/Query/bookFormats.ts b/backend/src/schema/books/resolvers/Query/bookFormats.ts new file mode 100644 index 0000000..2834071 --- /dev/null +++ b/backend/src/schema/books/resolvers/Query/bookFormats.ts @@ -0,0 +1,20 @@ +import { asc, count, desc, isNotNull } from "drizzle-orm"; +import type { QueryResolvers } from "~/schema/types.generated"; +import { db } from "~/db"; +import { books } from "~/db/schema"; + +export const bookFormats: NonNullable = async ( + _parent, + _args, + _ctx, +) => { + // Order by each format's book count, and then alphabetically + const rows = await db + .select({ bookFormat: books.bookFormat }) + .from(books) + .where(isNotNull(books.bookFormat)) + .groupBy(books.bookFormat) + .orderBy(desc(count()), asc(books.bookFormat)); + + return rows.flatMap((row) => (row.bookFormat ? [row.bookFormat] : [])); +}; diff --git a/backend/src/schema/books/resolvers/Query/genres.ts b/backend/src/schema/books/resolvers/Query/genres.ts new file mode 100644 index 0000000..cfadd24 --- /dev/null +++ b/backend/src/schema/books/resolvers/Query/genres.ts @@ -0,0 +1,20 @@ +import { asc, count, desc, eq } from "drizzle-orm"; +import type { QueryResolvers } from "~/schema/types.generated"; +import { db } from "~/db"; +import { bookGenres, bookToGenres } from "~/db/schema"; + +export const genres: NonNullable = async ( + _parent, + _args, + _ctx, +) => { + // Order by each genre's book count, and then alphabetically + const rows = await db + .select({ genre: bookGenres.genre }) + .from(bookGenres) + .leftJoin(bookToGenres, eq(bookToGenres.genreId, bookGenres.id)) + .groupBy(bookGenres.id) + .orderBy(desc(count(bookToGenres.bookId)), asc(bookGenres.genre)); + + return rows.map((row) => row.genre); +}; diff --git a/backend/src/schema/books/resolvers/Query/languages.ts b/backend/src/schema/books/resolvers/Query/languages.ts new file mode 100644 index 0000000..9802337 --- /dev/null +++ b/backend/src/schema/books/resolvers/Query/languages.ts @@ -0,0 +1,20 @@ +import { asc, count, desc, isNotNull } from "drizzle-orm"; +import type { QueryResolvers } from "~/schema/types.generated"; +import { db } from "~/db"; +import { books } from "~/db/schema"; + +export const languages: NonNullable = async ( + _parent, + _args, + _ctx, +) => { + // Order by each language's book count, and then alphabetically + const rows = await db + .select({ language: books.language }) + .from(books) + .where(isNotNull(books.language)) + .groupBy(books.language) + .orderBy(desc(count()), asc(books.language)); + + return rows.flatMap((row) => (row.language ? [row.language] : [])); +}; diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index afc7435..23f1076 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -1,6 +1,16 @@ -import { and, asc, ilike, type SQL } from "drizzle-orm"; +import { + and, + asc, + eq, + exists, + gte, + ilike, + lte, + or, + type SQL, +} from "drizzle-orm"; import { db } from "~/db"; -import { books } from "~/db/schema/books"; +import { bookGenres, books, bookToGenres } from "~/db/schema"; import type { QueryResolvers } from "~/schema/types.generated"; export const searchBooks: NonNullable = async ( @@ -13,12 +23,32 @@ export const searchBooks: NonNullable = async ( if (args.title) conditions.push(ilike(books.title, `%${args.title}%`)); if (args.series) conditions.push(ilike(books.series, `%${args.series}%`)); if (args.author) conditions.push(ilike(books.author, `%${args.author}%`)); - if (args.language) - conditions.push(ilike(books.language, `%${args.language}%`)); - if (args.bookFormat) - conditions.push(ilike(books.bookFormat, `%${args.bookFormat}%`)); + if (args.language) conditions.push(eq(books.language, args.language)); + if (args.bookFormat) conditions.push(eq(books.bookFormat, args.bookFormat)); if (args.publisher) conditions.push(ilike(books.publisher, `%${args.publisher}%`)); + if (args.minPages != null) conditions.push(gte(books.pages, args.minPages)); + if (args.maxPages != null) conditions.push(lte(books.pages, args.maxPages)); + + const genreConditions: SQL[] = []; + + for (const genre of args.genres ?? []) { + genreConditions.push( + exists( + db + .select() + .from(bookToGenres) + .innerJoin(bookGenres, eq(bookGenres.id, bookToGenres.genreId)) + .where( + and(eq(bookToGenres.bookId, books.id), eq(bookGenres.genre, genre)), + ), + ), + ); + } + + if (genreConditions.length > 0) { + conditions.push(or(...genreConditions) as SQL); + } // Here we build the filter once with Drizzle's SQL builder, so the same filter // can be used both for fetching one page and for counting all matches. diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index af3f5df..e2c0979 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -6,7 +6,7 @@ type Book { author: String! description: String language: String - bookFormat: String! + bookFormat: String edition: String pages: Int! publisher: String @@ -16,6 +16,11 @@ type Book { } extend type Query { + book(id: Int!): Book + genres: [String!]! + languages: [String!]! + bookFormats: [String!]! + searchBooks( title: String series: String @@ -23,10 +28,12 @@ extend type Query { language: String bookFormat: String publisher: String + genres: [String!] + minPages: Int + maxPages: Int limit: Int = 12 offset: Int = 0 ): BookSearchResult! - book(id: Int!): Book } type BookSearchResult { diff --git a/backend/src/schema/resolvers.generated.ts b/backend/src/schema/resolvers.generated.ts index 30e35fc..9439ab8 100644 --- a/backend/src/schema/resolvers.generated.ts +++ b/backend/src/schema/resolvers.generated.ts @@ -1,16 +1,19 @@ /* This file was automatically generated. DO NOT UPDATE MANUALLY. */ import type { Resolvers } from './types.generated'; import { book as Query_book } from './books/resolvers/Query/book'; +import { bookFormats as Query_bookFormats } from './books/resolvers/Query/bookFormats'; import { favoriteBookIds as Query_favoriteBookIds } from './favorites/resolvers/Query/favoriteBookIds'; import { favoriteBooks as Query_favoriteBooks } from './favorites/resolvers/Query/favoriteBooks'; import { favoriteGenres as Query_favoriteGenres } from './favorites/resolvers/Query/favoriteGenres'; +import { genres as Query_genres } from './books/resolvers/Query/genres'; +import { languages as Query_languages } from './books/resolvers/Query/languages'; import { searchBooks as Query_searchBooks } from './books/resolvers/Query/searchBooks'; import { addFavoriteBook as Mutation_addFavoriteBook } from './favorites/resolvers/Mutation/addFavoriteBook'; import { removeFavoriteBook as Mutation_removeFavoriteBook } from './favorites/resolvers/Mutation/removeFavoriteBook'; import { Book } from './books/resolvers/Book'; import { BookSearchResult } from './books/resolvers/BookSearchResult'; export const resolvers: Resolvers = { - Query: { book: Query_book,favoriteBookIds: Query_favoriteBookIds,favoriteBooks: Query_favoriteBooks,favoriteGenres: Query_favoriteGenres,searchBooks: Query_searchBooks }, + Query: { book: Query_book,bookFormats: Query_bookFormats,favoriteBookIds: Query_favoriteBookIds,favoriteBooks: Query_favoriteBooks,favoriteGenres: Query_favoriteGenres,genres: Query_genres,languages: Query_languages,searchBooks: Query_searchBooks }, Mutation: { addFavoriteBook: Mutation_addFavoriteBook,removeFavoriteBook: Mutation_removeFavoriteBook }, Book: Book, diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index d63ba0c..4375f88 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -1,6 +1,6 @@ type Book { author: String! - bookFormat: String! + bookFormat: String coverImgUrl: String description: String edition: String @@ -27,6 +27,7 @@ type Mutation { type Query { book(id: Int!): Book + bookFormats: [String!]! """ ID of every book the logged-in user that has been added to their favorites. @@ -38,5 +39,7 @@ type Query { """The logged-in user's most common genres among their favorite books.""" favoriteGenres(limit: Int = 3): [String!]! - searchBooks(author: String, bookFormat: String, language: String, limit: Int = 12, offset: Int = 0, publisher: String, series: String, title: String): BookSearchResult! + genres: [String!]! + languages: [String!]! + searchBooks(author: String, bookFormat: String, genres: [String!], language: String, limit: Int = 12, maxPages: Int, minPages: Int, offset: Int = 0, publisher: String, series: String, title: String): BookSearchResult! } \ No newline at end of file diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 8b6a17b..5604351 100644 --- a/backend/src/schema/typeDefs.generated.ts +++ b/backend/src/schema/typeDefs.generated.ts @@ -1,2 +1,2 @@ import type { DocumentNode } from 'graphql'; - export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode \ No newline at end of file + export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"languages"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormats"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"maxPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode \ No newline at end of file diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts index 64679ed..a8a9c60 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -17,7 +17,7 @@ export type Scalars = { export type Book = { __typename?: 'Book'; author: Scalars['String']['output']; - bookFormat: Scalars['String']['output']; + bookFormat?: Maybe; coverImgUrl?: Maybe; description?: Maybe; edition?: Maybe; @@ -57,12 +57,15 @@ export type MutationremoveFavoriteBookArgs = { export type Query = { __typename?: 'Query'; book?: Maybe; + bookFormats: Array; /** ID of every book the logged-in user that has been added to their favorites. */ favoriteBookIds: Array; /** Favorite books of the logged-in user, newest first. */ favoriteBooks: BookSearchResult; /** The logged-in user's most common genres among their favorite books. */ favoriteGenres: Array; + genres: Array; + languages: Array; searchBooks: BookSearchResult; }; @@ -86,8 +89,11 @@ export type QueryfavoriteGenresArgs = { export type QuerysearchBooksArgs = { author?: InputMaybe; bookFormat?: InputMaybe; + genres?: InputMaybe>; language?: InputMaybe; limit?: InputMaybe; + maxPages?: InputMaybe; + minPages?: InputMaybe; offset?: InputMaybe; publisher?: InputMaybe; series?: InputMaybe; @@ -189,7 +195,7 @@ export type ResolversParentTypes = { export type BookResolvers = { author?: Resolver; - bookFormat?: Resolver; + bookFormat?: Resolver, ParentType, ContextType>; coverImgUrl?: Resolver, ParentType, ContextType>; description?: Resolver, ParentType, ContextType>; edition?: Resolver, ParentType, ContextType>; @@ -216,9 +222,12 @@ export type MutationResolvers = { book?: Resolver, ParentType, ContextType, RequireFields>; + bookFormats?: Resolver, ParentType, ContextType>; favoriteBookIds?: Resolver, ParentType, ContextType>; favoriteBooks?: Resolver>; favoriteGenres?: Resolver, ParentType, ContextType, RequireFields>; + genres?: Resolver, ParentType, ContextType>; + languages?: Resolver, ParentType, ContextType>; searchBooks?: Resolver>; }; diff --git a/frontend/app/api/languageCodes.ts b/frontend/app/api/languageCodes.ts deleted file mode 100644 index 24e9d8a..0000000 --- a/frontend/app/api/languageCodes.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Original ISO 639-1 language codes fetched from: -// https://gist.github.com/jrnk/8eb57b065ea0b098d571 -// This is a smaller selection of languages and the codes are adjusted -// to the first three letters of the language name, as used by Open Library. -const languageCodes = [ - { code: "afr", name: "Afrikaans" }, - { code: "ara", name: "Arabic" }, - { code: "aze", name: "Azerbaijani" }, - { code: "bel", name: "Belarusian" }, - { code: "bul", name: "Bulgarian" }, - { code: "tib", name: "Tibetan" }, - { code: "bos", name: "Bosnian" }, - { code: "cze", name: "Czech" }, - { code: "wel", name: "Welsh" }, - { code: "dan", name: "Danish" }, - { code: "ger", name: "German" }, - { code: "gre", name: "Greek, Modern (1453-)" }, - { code: "eng", name: "English" }, - { code: "esp", name: "Esperanto" }, - { code: "spa", name: "Spanish; Castilian" }, - { code: "est", name: "Estonian" }, - { code: "per", name: "Persian" }, - { code: "fin", name: "Finnish" }, - { code: "fre", name: "French" }, - { code: "iri", name: "Irish" }, - { code: "hin", name: "Hindi" }, - { code: "cro", name: "Croatian" }, - { code: "hun", name: "Hungarian" }, - { code: "arm", name: "Armenian" }, - { code: "ind", name: "Indonesian" }, - { code: "ice", name: "Icelandic" }, - { code: "ita", name: "Italian" }, - { code: "jpn", name: "Japanese" }, - { code: "geo", name: "Georgian" }, - { code: "kor", name: "Korean" }, - { code: "kur", name: "Kurdish" }, - { code: "lit", name: "Lithuanian" }, - { code: "lat", name: "Latvian" }, - { code: "mac", name: "Macedonian" }, - { code: "mon", name: "Mongolian" }, - { code: "bur", name: "Burmese" }, - { code: "dut", name: "Dutch; Flemish" }, - { code: "nor", name: "Norwegian" }, - { code: "pan", name: "Panjabi; Punjabi" }, - { code: "pol", name: "Polish" }, - { code: "por", name: "Portuguese" }, - { code: "rom", name: "Romanian; Moldavian; Moldovan" }, - { code: "rus", name: "Russian" }, - { code: "alb", name: "Albanian" }, - { code: "ser", name: "Serbian" }, - { code: "swe", name: "Swedish" }, - { code: "tur", name: "Turkish" }, - { code: "ukr", name: "Ukrainian" }, - { code: "vie", name: "Vietnamese" }, - { code: "chi", name: "Chinese" }, -] as const; - -export { languageCodes }; diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index 905db23..044a5fd 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -9,6 +9,11 @@ export type BookQueryVariables = Exact<{ export type BookQuery = { book: { __typename: 'Book', id: number, title: string, author: string, publishDate: string | null, coverImgUrl: string | null, description: string | null } | null }; +export type BookFiltersQueryVariables = Exact<{ [key: string]: never; }>; + + +export type BookFiltersQuery = { genres: Array, languages: Array, bookFormats: Array }; + export type FavoriteBooksQueryVariables = Exact<{ limit?: number | null | undefined; offset?: number | null | undefined; @@ -38,6 +43,11 @@ export type RemoveFavoriteBookMutation = { removeFavoriteBook: boolean }; export type SearchBooksQueryVariables = Exact<{ title?: string | null | undefined; + genres?: Array | string | null | undefined; + language?: string | null | undefined; + bookFormat?: string | null | undefined; + minPages?: number | null | undefined; + maxPages?: number | null | undefined; limit?: number | null | undefined; offset?: number | null | undefined; }>; diff --git a/frontend/app/api/types/index.ts b/frontend/app/api/types/index.ts index 189e9ab..7d517b8 100644 --- a/frontend/app/api/types/index.ts +++ b/frontend/app/api/types/index.ts @@ -7,10 +7,8 @@ export interface Book { } export interface BookFilters { - author?: string; - subject?: string; - place?: string; - person?: string; + genres?: string[]; language?: string; - publisher?: string; + bookFormat?: string; + pages?: { min?: number; max?: number }; } diff --git a/frontend/app/api/useBookFilters.ts b/frontend/app/api/useBookFilters.ts new file mode 100644 index 0000000..734b873 --- /dev/null +++ b/frontend/app/api/useBookFilters.ts @@ -0,0 +1,28 @@ +import { gql, type TypedDocumentNode } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; +import type { + BookFiltersQuery, + BookFiltersQueryVariables, +} from "~/api/types/graphql.generated"; + +const BOOK_FILTERS: TypedDocumentNode< + BookFiltersQuery, + BookFiltersQueryVariables +> = gql` + query BookFilters { + genres + languages + bookFormats + } +`; + +export const useBookFilters = () => { + const { data, loading, error } = useQuery(BOOK_FILTERS); + return { + genres: data?.genres ?? [], + languages: data?.languages ?? [], + bookFormats: data?.bookFormats ?? [], + loading, + error, + }; +}; diff --git a/frontend/app/api/useBookSearchParams.ts b/frontend/app/api/useBookSearchParams.ts index 037fe24..520434a 100644 --- a/frontend/app/api/useBookSearchParams.ts +++ b/frontend/app/api/useBookSearchParams.ts @@ -1,31 +1,35 @@ import { useMemo } from "react"; import { useSearchParams } from "react-router"; +import type { BookFilters } from "~/api/types"; + +function parsePageCount(value: string | null) { + const count = Number(value); + return value && Number.isInteger(count) && count >= 0 ? count : undefined; +} function useBookSearchParams() { const [searchParams] = useSearchParams(); - const rawQuery = searchParams.get("q") ?? ""; + const query = searchParams.get("q") ?? ""; const page = parseInt(searchParams.get("page") ?? "1", 10); - const author = searchParams.get("author") ?? undefined; - const subject = searchParams.get("subject") ?? undefined; - const place = searchParams.get("place") ?? undefined; - const person = searchParams.get("person") ?? undefined; - const language = searchParams.get("language") ?? undefined; - const publisher = searchParams.get("publisher") ?? undefined; - - // Only update the filters object when one of the individual filter values changes - const filters = useMemo( - () => ({ author, subject, place, person, language, publisher }), - [author, subject, place, person, language, publisher], - ); - - const hasFilters = - Object.values(filters).filter((value) => value !== undefined).length > 0; - - const query = rawQuery || hasFilters ? rawQuery : "subject:fantasy"; - - return { rawQuery, query, page, filters }; + // Only update filters when searchParams change to avoid unnecessary re-renders + const filters = useMemo((): BookFilters => { + const genres = searchParams.getAll("genre"); + const minPages = parsePageCount(searchParams.get("minPages")); + const maxPages = parsePageCount(searchParams.get("maxPages")); + return { + genres: genres.length > 0 ? genres : undefined, + language: searchParams.get("language") ?? undefined, + bookFormat: searchParams.get("bookFormat") ?? undefined, + pages: + minPages === undefined && maxPages === undefined + ? undefined + : { min: minPages, max: maxPages }, + }; + }, [searchParams]); + + return { query, page, filters }; } export { useBookSearchParams }; diff --git a/frontend/app/api/useSearchBooks.ts b/frontend/app/api/useSearchBooks.ts index a52c2fc..5179808 100644 --- a/frontend/app/api/useSearchBooks.ts +++ b/frontend/app/api/useSearchBooks.ts @@ -1,6 +1,7 @@ import { gql, type TypedDocumentNode } from "@apollo/client"; import { useQuery } from "@apollo/client/react"; import { toBook } from "~/api/toBook"; +import type { BookFilters } from "~/api/types"; import type { SearchBooksQuery, SearchBooksQueryVariables, @@ -12,8 +13,26 @@ const SEARCH_BOOKS: TypedDocumentNode< SearchBooksQuery, SearchBooksQueryVariables > = gql` - query SearchBooks($title: String, $limit: Int, $offset: Int) { - searchBooks(title: $title, limit: $limit, offset: $offset) { + query SearchBooks( + $title: String + $genres: [String!] + $language: String + $bookFormat: String + $minPages: Int + $maxPages: Int + $limit: Int + $offset: Int + ) { + searchBooks( + title: $title + genres: $genres + language: $language + bookFormat: $bookFormat + minPages: $minPages + maxPages: $maxPages + limit: $limit + offset: $offset + ) { totalCount books { id @@ -28,11 +47,21 @@ const SEARCH_BOOKS: TypedDocumentNode< export const useSearchBooks = ( title: string, + filters: BookFilters, limit: number, offset: number, ) => { const { data, loading, error } = useQuery(SEARCH_BOOKS, { - variables: { title, limit, offset }, + variables: { + title, + genres: filters.genres, + language: filters.language, + bookFormat: filters.bookFormat, + minPages: filters.pages?.min, + maxPages: filters.pages?.max, + limit, + offset, + }, }); return { books: data?.searchBooks.books.map(toBook) ?? [], diff --git a/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx b/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx new file mode 100644 index 0000000..0061fcd --- /dev/null +++ b/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createRoutesStub, Link, useSearchParams } from "react-router"; +import { FilterBooksSection } from "~/components/FilterBooksSection/FilterBooksSection"; + +vi.mock("~/api/useBookFilters", () => ({ + useBookFilters: () => ({ + genres: ["Fantasy", "Romance", "Science Fiction"], + languages: ["English", "French", "Spanish"], + loading: false, + error: undefined, + }), +})); + +// Shows the current filter params so tests can assert what was written to the URL. +function SearchParamProbe() { + const [searchParams] = useSearchParams(); + return ( + <> +

{searchParams.getAll("genre").join(",")}

+

{searchParams.get("language")}

+

+ {searchParams.get("minPages")}-{searchParams.get("maxPages")} +

+ + ); +} + +const Stub = createRoutesStub([ + { + path: "/", + Component: () => ( + <> + + + Go to 250+ pages + + ), + }, +]); + +describe("FilterBooksSection", () => { + it("shows the selected genres from the URL", () => { + render(); + + expect(screen.getByText("Fantasy")).toBeInTheDocument(); + expect(screen.getByText("Romance")).toBeInTheDocument(); + }); + + it("adds a selected genre to the URL", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Genres"), "Sci"); + await user.click( + await screen.findByRole("option", { name: "Science Fiction" }), + ); + + await waitFor(() => + expect(screen.getByTestId("genres")).toHaveTextContent( + "Fantasy,Science Fiction", + ), + ); + }); + + it("shows the selected language from the URL", () => { + render(); + + expect(screen.getByLabelText("Language")).toHaveValue("French"); + }); + + it("sets the selected language in the URL", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Language"), "Spa"); + await user.click(await screen.findByRole("option", { name: "Spanish" })); + + await waitFor(() => + expect(screen.getByTestId("language")).toHaveTextContent("Spanish"), + ); + }); + + it("shows the page range from the URL", () => { + render(); + + expect(screen.getByLabelText("Minimum pages")).toHaveValue(100); + expect(screen.getByLabelText("Maximum pages")).toHaveValue(500); + }); + + it("defaults the page range to 0 and the slider end", () => { + render(); + + expect(screen.getByLabelText("Minimum pages")).toHaveValue(0); + expect(screen.getByLabelText("Maximum pages")).toHaveValue(2000); + }); + + it("writes a typed max to the URL on Enter", async () => { + const user = userEvent.setup(); + render(); + + const maxInput = screen.getByLabelText("Maximum pages"); + await user.clear(maxInput); + await user.type(maxInput, "300{Enter}"); + + await waitFor(() => + expect(screen.getByTestId("pages")).toHaveTextContent("-300"), + ); + }); + + it("swaps the range when min is above max", async () => { + const user = userEvent.setup(); + render(); + + const minInput = screen.getByLabelText("Minimum pages"); + await user.clear(minInput); + await user.type(minInput, "500"); + await user.tab(); + + await waitFor(() => + expect(screen.getByTestId("pages")).toHaveTextContent("300-500"), + ); + }); + + it("keeps focus when tabbing from min to max", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Minimum pages"), "{Backspace}100"); + await user.tab(); + + await waitFor(() => + expect(screen.getByTestId("pages")).toHaveTextContent("100-"), + ); + expect(screen.getByLabelText("Maximum pages")).toHaveFocus(); + }); + + it("removes the params when the range is reset to the defaults", async () => { + const user = userEvent.setup(); + render(); + + const minInput = screen.getByLabelText("Minimum pages"); + await user.clear(minInput); + await user.type(minInput, "0"); + const maxInput = screen.getByLabelText("Maximum pages"); + await user.clear(maxInput); + await user.type(maxInput, "99999{Enter}"); + + await waitFor(() => + expect(screen.getByTestId("pages")).toHaveTextContent(/^-$/), + ); + }); + + it("updates the page range when the URL changes", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("link", { name: "Go to 250+ pages" })); + + expect(screen.getByLabelText("Minimum pages")).toHaveValue(250); + expect(screen.getByLabelText("Maximum pages")).toHaveValue(2000); + }); + + it("treats an empty max as no upper limit", async () => { + const user = userEvent.setup(); + render(); + + const maxInput = screen.getByLabelText("Maximum pages"); + await user.clear(maxInput); + await user.tab(); + + expect(maxInput).toHaveValue(2000); + await waitFor(() => + expect(screen.getByTestId("pages")).toHaveTextContent(/^-$/), + ); + }); + + it("follows the URL again after a committed edit", async () => { + const user = userEvent.setup(); + render(); + + await user.type( + screen.getByLabelText("Minimum pages"), + "{Backspace}100{Enter}", + ); + await waitFor(() => + expect(screen.getByTestId("pages")).toHaveTextContent("100-"), + ); + await user.click(screen.getByRole("link", { name: "Go to 250+ pages" })); + + expect(screen.getByLabelText("Minimum pages")).toHaveValue(250); + }); +}); diff --git a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx index 7f2fd0b..90a935f 100644 --- a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx +++ b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx @@ -1,112 +1,36 @@ -import type { BookFilters } from "~/api/types"; -import { languageCodes } from "~/api/languageCodes"; -import { CircleQuestionMark } from "lucide-react"; -import { Input } from "~/components/ui/Input"; -import { Label } from "~/components/ui/Label"; import { Button } from "~/components/ui/Button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/Select"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "~/components/ui/Tooltip"; import { useSearchParams } from "react-router"; import { useBookSearchParams } from "~/api/useBookSearchParams"; -import { useDebounce } from "~/api/useDebounce"; -import { useEffect, useRef, useState } from "react"; - -const FILTER_INPUTS: { - name: keyof BookFilters; - placeholder: string; - ariaLabel: string; -}[] = [ - { - name: "author", - placeholder: "Search book authors...", - ariaLabel: "Search for book author", - }, - { - name: "subject", - placeholder: "Search book subjects...", - ariaLabel: "Search for book subject", - }, - { - name: "place", - placeholder: "Search places... (e.g., New York, London)", - ariaLabel: "Search for place", - }, - { - name: "person", - placeholder: "Search for people...", - ariaLabel: "Search for person", - }, - { - name: "publisher", - placeholder: "Search for publishers...", - ariaLabel: "Search for publisher", - }, -]; - -const FILTER_KEYS = [...FILTER_INPUTS.map((input) => input.name), "language"]; - -const LANGUAGE_ITEMS = [ - { - value: "none", - label: "Select a language", - }, - ...languageCodes.map((lang) => ({ - value: lang.code, - label: lang.name, - })), -]; +import { useBookFilters } from "~/api/useBookFilters"; +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxValue, + useComboboxAnchor, +} from "~/components/ui/Combobox"; +import { PagesFilter } from "~/components/PagesFilter/PagesFilter"; function FilterBooksSection() { const [, setSearchParams] = useSearchParams(); - const { filters: rawFilters } = useBookSearchParams(); - - const [filters, setFilters] = useState(rawFilters); - const newFiltersFromInputRef = useRef(false); - - const debouncedFilters = useDebounce(filters, 500); + const { filters } = useBookSearchParams(); + const { genres, languages, bookFormats } = useBookFilters(); + const genresAnchor = useComboboxAnchor(); - useEffect(() => { - // If the filters are not changed using the input, e.g. browser navigation, - // we want to use those filters immediately. - if (!newFiltersFromInputRef.current) { - setFilters(rawFilters); - return; - } - - setSearchParams((searchParams) => { - Object.entries(debouncedFilters).forEach(([key, value]) => { - if (value && value.length > 0) { - searchParams.set(key, value); - } else { - searchParams.delete(key); - } - }); - return searchParams; - }); - - newFiltersFromInputRef.current = false; - }, [debouncedFilters, rawFilters, setSearchParams]); - - const hasActiveFilters = Object.values(rawFilters).some(Boolean); + const hasActiveFilters = Object.values(filters).some(Boolean); const clearFilters = () => { - // Cancel any filter change that are currently being typed but not yet debounced - newFiltersFromInputRef.current = false; - setSearchParams((searchParams) => { - FILTER_KEYS.forEach((key) => searchParams.delete(key)); + Object.keys(filters).forEach((key) => searchParams.delete(key)); - // Reset the page to 1 when clearing filters. A missing "page" means page 1 + // Reset the page to 1 when clearing filters. + // A missing "page" search param means page 1 searchParams.delete("page"); return searchParams; @@ -129,69 +53,126 @@ function FilterBooksSection() {
    - {FILTER_INPUTS.map((input) => ( -
  • - - { - newFiltersFromInputRef.current = true; - setFilters({ ...filters, [input.name]: e.target.value }); - }} - /> -
  • - ))} -
  • - - - - } - > - - - Only show books that have an edition in the selected language - - - - - + className="bg-white" + placeholder="Search languages..." + showClear + /> + + No languages found. + + {(language: string) => ( + + {language} + + )} + + + +
  • +
  • + + { + setSearchParams((searchParams) => { + if (bookFormat) { + searchParams.set("bookFormat", bookFormat); + } else { + searchParams.delete("bookFormat"); + } + searchParams.delete("page"); + return searchParams; + }); + }} + > + + + No book formats found. + + {(bookFormat: string) => ( + + {bookFormat} + + )} + + + +
  • +
  • +
diff --git a/frontend/app/components/PagesFilter/PagesFilter.tsx b/frontend/app/components/PagesFilter/PagesFilter.tsx new file mode 100644 index 0000000..d420e36 --- /dev/null +++ b/frontend/app/components/PagesFilter/PagesFilter.tsx @@ -0,0 +1,131 @@ +import { useState, type ComponentProps } from "react"; +import { useSearchParams } from "react-router"; +import { useBookSearchParams } from "~/api/useBookSearchParams"; +import { Input } from "~/components/ui/Input"; +import { Slider } from "~/components/ui/Slider"; + +const PAGES_LIMIT = 2000; + +interface PagesDraft { + min: number | null; + max: number | null; + committed?: boolean; +} + +function PagesFilter() { + const [, setSearchParams] = useSearchParams(); + const { filters } = useBookSearchParams(); + + const [draft, setDraft] = useState(null); + + const urlMin = filters.pages?.min ?? 0; + const urlMax = filters.pages?.max ?? PAGES_LIMIT; + + if (draft?.committed && draft.min === urlMin && draft.max === urlMax) { + setDraft(null); + } + + const min = draft ? draft.min : urlMin; + const max = draft ? draft.max : urlMax; + + // An empty min means 0 and an empty max means no upper limit + const currentMin = Math.min(Math.max(min ?? 0, 0), PAGES_LIMIT); + const currentMax = Math.min(Math.max(max ?? PAGES_LIMIT, 0), PAGES_LIMIT); + + function commit(newMin: number, newMax: number) { + const low = Math.min(newMin, newMax); + const high = Math.max(newMin, newMax); + setDraft({ min: low, max: high, committed: true }); + setSearchParams((searchParams) => { + if (low > 0) { + searchParams.set("minPages", String(low)); + } else { + searchParams.delete("minPages"); + } + if (high < PAGES_LIMIT) { + searchParams.set("maxPages", String(high)); + } else { + searchParams.delete("maxPages"); + } + searchParams.delete("page"); + return searchParams; + }); + } + + function commitInputs() { + // Blurring an input that wasn't edited shouldn't add a history entry + if (draft && !draft.committed) commit(currentMin, currentMax); + } + + return ( +
+ + Book length (pages) + + { + const [newMin, newMax] = values as number[]; + setDraft({ min: newMin, max: newMax }); + }} + onValueCommitted={(values) => { + const [newMin, newMax] = values as number[]; + commit(newMin, newMax); + }} + /> +
+ + setDraft({ + min: Number.isNaN(parseInt(event.target.value)) + ? null + : parseInt(event.target.value), + max, + }) + } + onCommit={commitInputs} + /> + + + setDraft({ + min, + max: Number.isNaN(parseInt(event.target.value)) + ? null + : parseInt(event.target.value), + }) + } + onCommit={commitInputs} + /> +
+
+ ); +} + +function PageInput({ + onCommit, + ...props +}: ComponentProps & { onCommit: () => void }) { + return ( + { + if (event.key === "Enter") onCommit(); + }} + {...props} + /> + ); +} + +export { PagesFilter }; diff --git a/frontend/app/components/SearchBar/SearchBar.test.tsx b/frontend/app/components/SearchBar/SearchBar.test.tsx index 157b639..ce55b4c 100644 --- a/frontend/app/components/SearchBar/SearchBar.test.tsx +++ b/frontend/app/components/SearchBar/SearchBar.test.tsx @@ -51,11 +51,17 @@ describe("SearchBar", () => { }); it("shows the number of active filters", () => { - render(); + render(); expect( screen.getByRole("button", { name: "Filter books" }), ).toBeInTheDocument(); expect(screen.getByLabelText("2 filters active")).toHaveTextContent("2"); }); + + it("counts the page range as one filter", () => { + render(); + + expect(screen.getByLabelText("1 filter active")).toHaveTextContent("1"); + }); }); diff --git a/frontend/app/components/SearchBar/SearchBar.tsx b/frontend/app/components/SearchBar/SearchBar.tsx index e1c44b5..ef330ba 100644 --- a/frontend/app/components/SearchBar/SearchBar.tsx +++ b/frontend/app/components/SearchBar/SearchBar.tsx @@ -26,7 +26,7 @@ interface SearchBarProps { } function SearchBar({ resultCount }: SearchBarProps) { - const { rawQuery: query, filters } = useBookSearchParams(); + const { query, filters } = useBookSearchParams(); const [, setSearchParams] = useSearchParams(); const [inputField, setInputField] = useState(query); diff --git a/frontend/app/components/ui/Slider.tsx b/frontend/app/components/ui/Slider.tsx new file mode 100644 index 0000000..88a6b51 --- /dev/null +++ b/frontend/app/components/ui/Slider.tsx @@ -0,0 +1,51 @@ +import { Slider as SliderPrimitive } from "@base-ui/react/slider"; +import { cn } from "cn"; + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: SliderPrimitive.Root.Props) { + const _values = Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max]; + + return ( + + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + + ); +} + +export { Slider }; diff --git a/frontend/app/routes/book/selectedBook.tsx b/frontend/app/routes/book/selectedBook.tsx index cf9c0a5..5c10ee4 100644 --- a/frontend/app/routes/book/selectedBook.tsx +++ b/frontend/app/routes/book/selectedBook.tsx @@ -23,12 +23,13 @@ const DISABLED_STYLE = "pointer-events-none opacity-50"; export default function SelectedBookPage({ params }: Route.ComponentProps) { const [searchParams] = useSearchParams(); - const { rawQuery, page } = useBookSearchParams(); + const { query, page, filters } = useBookSearchParams(); const { book, loading, error, description } = useBook(params.bookId); const { books } = useSearchBooks( - rawQuery, + query, + filters, SEARCH_LIMIT, (page - 1) * SEARCH_LIMIT, ); diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx index 56ef996..5cb1b22 100644 --- a/frontend/app/routes/home.tsx +++ b/frontend/app/routes/home.tsx @@ -11,13 +11,12 @@ export function meta() { ]; } -// Eslint fails while filters is not used. Kept since it will be used in the future, -// and will be fixed before milestone 2. export default function HomePage() { - const { rawQuery, page } = useBookSearchParams(); + const { query, page, filters } = useBookSearchParams(); const { books, totalCount, loading, error } = useSearchBooks( - rawQuery, + query, + filters, SEARCH_LIMIT, (page - 1) * SEARCH_LIMIT, );