From 60a510d5d35c2477e81e21ecd86ceca2833bc660 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 21:57:54 +0200 Subject: [PATCH 01/24] feat: update db seeding script to insert book genres --- backend/src/db/seed/index.ts | 57 +++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/backend/src/db/seed/index.ts b/backend/src/db/seed/index.ts index a095323..cdb87be 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,7 +19,7 @@ type BookRow = { description: string | null; language: string | null; isbn: string; - genres: string[]; + genres: string; characters: string[]; bookFormat: string; edition: 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") { @@ -70,10 +112,17 @@ for await (const book of parser as AsyncIterable) { coverImgUrl: book.coverImg || 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!"); From 6eba72333d6acb8b6cffc7b4862dc1e1cf95219d Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 21:58:04 +0200 Subject: [PATCH 02/24] fix: force book genres to be unique --- backend/src/db/schema/books.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/db/schema/books.ts b/backend/src/db/schema/books.ts index 99b66d7..384e7b2 100644 --- a/backend/src/db/schema/books.ts +++ b/backend/src/db/schema/books.ts @@ -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( From 1b0b3cdcd7956ed5eeabd9b150d886caece180db Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 22:19:30 +0200 Subject: [PATCH 03/24] feat: add GraphQL endpoint for genres --- .../src/schema/books/resolvers/Query/genres.ts | 17 +++++++++++++++++ backend/src/schema/books/schema.graphql | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 backend/src/schema/books/resolvers/Query/genres.ts 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..cacee00 --- /dev/null +++ b/backend/src/schema/books/resolvers/Query/genres.ts @@ -0,0 +1,17 @@ +import { asc } from "drizzle-orm"; +import type { QueryResolvers } from "~/schema/types.generated"; +import { db } from "~/db"; +import { bookGenres } from "~/db/schema"; + +export const genres: NonNullable = async ( + _parent, + _args, + _ctx, +) => { + const rows = await db + .select({ genre: bookGenres.genre }) + .from(bookGenres) + .orderBy(asc(bookGenres.genre)); + + return rows.map((row) => row.genre); +}; diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index af3f5df..0d5b108 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -16,6 +16,9 @@ type Book { } extend type Query { + book(id: Int!): Book + genres: [String!]! + searchBooks( title: String series: String @@ -23,10 +26,10 @@ extend type Query { language: String bookFormat: String publisher: String + genres: [String!] limit: Int = 12 offset: Int = 0 ): BookSearchResult! - book(id: Int!): Book } type BookSearchResult { From bea8b4b7eb623df08aaa5bb9c0255261b987b7cd Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 22:20:05 +0200 Subject: [PATCH 04/24] feat: allow filtering books based on genre --- .../books/resolvers/Query/searchBooks.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index afc7435..10d2d72 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -1,6 +1,6 @@ -import { and, asc, ilike, type SQL } from "drizzle-orm"; +import { and, asc, eq, exists, ilike, 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 ( @@ -20,6 +20,20 @@ export const searchBooks: NonNullable = async ( if (args.publisher) conditions.push(ilike(books.publisher, `%${args.publisher}%`)); + for (const genre of args.genres ?? []) { + conditions.push( + exists( + db + .select() + .from(bookToGenres) + .innerJoin(bookGenres, eq(bookGenres.id, bookToGenres.genreId)) + .where( + and(eq(bookToGenres.bookId, books.id), eq(bookGenres.genre, genre)), + ), + ), + ); + } + // 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. const filter = and(...conditions); From 052de9ce077c7483988f204823665c446c545177 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 22:20:22 +0200 Subject: [PATCH 05/24] fix: import better-auth util from correct package --- frontend/app/api/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/api/auth.ts b/frontend/app/api/auth.ts index cd7c5a0..e535eb9 100644 --- a/frontend/app/api/auth.ts +++ b/frontend/app/api/auth.ts @@ -1,5 +1,5 @@ import { createAuthClient } from "better-auth/react"; -import { inferAdditionalFields } from "better-auth/react/plugins"; +import { inferAdditionalFields } from "better-auth/client/plugins"; const authClient = createAuthClient({ baseURL: "http://localhost:3001", From 0a5927d5d1a47bcfc89d950cf10abc7736784ef8 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 22:25:30 +0200 Subject: [PATCH 06/24] fix: book genres we're searching for should be OR, not AND It's enough that a book is in at least one book genre --- .../src/schema/books/resolvers/Query/searchBooks.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index 10d2d72..00dc66c 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, exists, ilike, type SQL } from "drizzle-orm"; +import { and, asc, eq, exists, ilike, or, type SQL } from "drizzle-orm"; import { db } from "~/db"; import { bookGenres, books, bookToGenres } from "~/db/schema"; import type { QueryResolvers } from "~/schema/types.generated"; @@ -20,8 +20,10 @@ export const searchBooks: NonNullable = async ( if (args.publisher) conditions.push(ilike(books.publisher, `%${args.publisher}%`)); + const genreConditions: SQL[] = []; + for (const genre of args.genres ?? []) { - conditions.push( + genreConditions.push( exists( db .select() @@ -34,6 +36,10 @@ export const searchBooks: NonNullable = async ( ); } + 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. const filter = and(...conditions); From 05bb2b90868baa3e1280df5292e3ef83021dc02a Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 22:39:19 +0200 Subject: [PATCH 07/24] chore: generate GraphQL types --- backend/src/schema/resolvers.generated.ts | 3 ++- backend/src/schema/schema.generated.graphqls | 3 ++- backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 3 +++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/src/schema/resolvers.generated.ts b/backend/src/schema/resolvers.generated.ts index 30e35fc..eefa8eb 100644 --- a/backend/src/schema/resolvers.generated.ts +++ b/backend/src/schema/resolvers.generated.ts @@ -4,13 +4,14 @@ 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 { 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,favoriteBookIds: Query_favoriteBookIds,favoriteBooks: Query_favoriteBooks,favoriteGenres: Query_favoriteGenres,genres: Query_genres,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..1518db7 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -38,5 +38,6 @@ 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!]! + searchBooks(author: String, bookFormat: String, genres: [String!], language: String, limit: Int = 12, 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..5f3a4a2 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":"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":"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":"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 diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts index 64679ed..6df5338 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -63,6 +63,7 @@ export type Query = { favoriteBooks: BookSearchResult; /** The logged-in user's most common genres among their favorite books. */ favoriteGenres: Array; + genres: Array; searchBooks: BookSearchResult; }; @@ -86,6 +87,7 @@ export type QueryfavoriteGenresArgs = { export type QuerysearchBooksArgs = { author?: InputMaybe; bookFormat?: InputMaybe; + genres?: InputMaybe>; language?: InputMaybe; limit?: InputMaybe; offset?: InputMaybe; @@ -219,6 +221,7 @@ export type QueryResolvers, ParentType, ContextType>; favoriteBooks?: Resolver>; favoriteGenres?: Resolver, ParentType, ContextType, RequireFields>; + genres?: Resolver, ParentType, ContextType>; searchBooks?: Resolver>; }; From 2cdf8935d3a5325937555a32d15669ccdc691e64 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 22:41:37 +0200 Subject: [PATCH 08/24] feat: add genre filtering to frontend --- frontend/app/api/languageCodes.ts | 58 ------- frontend/app/api/types/index.ts | 7 +- frontend/app/api/useBookSearchParams.ts | 29 ++-- frontend/app/api/useGenres.ts | 17 ++ frontend/app/api/useSearchBooks.ts | 17 +- .../FilterBooksSection/FilterBooksSection.tsx | 164 ++++++------------ .../app/components/SearchBar/SearchBar.tsx | 2 +- frontend/app/routes/book/selectedBook.tsx | 5 +- frontend/app/routes/home.tsx | 7 +- 9 files changed, 104 insertions(+), 202 deletions(-) delete mode 100644 frontend/app/api/languageCodes.ts create mode 100644 frontend/app/api/useGenres.ts 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/index.ts b/frontend/app/api/types/index.ts index 189e9ab..7480b97 100644 --- a/frontend/app/api/types/index.ts +++ b/frontend/app/api/types/index.ts @@ -7,10 +7,5 @@ export interface Book { } export interface BookFilters { - author?: string; - subject?: string; - place?: string; - person?: string; - language?: string; - publisher?: string; + genres?: string[]; } diff --git a/frontend/app/api/useBookSearchParams.ts b/frontend/app/api/useBookSearchParams.ts index 037fe24..b5f8ed4 100644 --- a/frontend/app/api/useBookSearchParams.ts +++ b/frontend/app/api/useBookSearchParams.ts @@ -1,31 +1,22 @@ import { useMemo } from "react"; import { useSearchParams } from "react-router"; +import type { BookFilters } from "~/api/types"; 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 filters when searchParams change to avoid unnecessary re-renders + const filters = useMemo((): BookFilters => { + const genres = searchParams.getAll("genre"); + return { + genres: genres.length > 0 ? genres : undefined, + }; + }, [searchParams]); - // 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 }; + return { query, page, filters }; } export { useBookSearchParams }; diff --git a/frontend/app/api/useGenres.ts b/frontend/app/api/useGenres.ts new file mode 100644 index 0000000..d6f9a3e --- /dev/null +++ b/frontend/app/api/useGenres.ts @@ -0,0 +1,17 @@ +import { gql, type TypedDocumentNode } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; +import type { + GenresQuery, + GenresQueryVariables, +} from "~/api/types/graphql.generated"; + +const GENRES: TypedDocumentNode = gql` + query Genres { + genres + } +`; + +export const useGenres = () => { + const { data, loading, error } = useQuery(GENRES); + return { genres: data?.genres ?? [], loading, error }; +}; diff --git a/frontend/app/api/useSearchBooks.ts b/frontend/app/api/useSearchBooks.ts index a52c2fc..5a91192 100644 --- a/frontend/app/api/useSearchBooks.ts +++ b/frontend/app/api/useSearchBooks.ts @@ -12,8 +12,18 @@ 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!] + $limit: Int + $offset: Int + ) { + searchBooks( + title: $title + genres: $genres + limit: $limit + offset: $offset + ) { totalCount books { id @@ -28,11 +38,12 @@ const SEARCH_BOOKS: TypedDocumentNode< export const useSearchBooks = ( title: string, + genres: string[], limit: number, offset: number, ) => { const { data, loading, error } = useQuery(SEARCH_BOOKS, { - variables: { title, limit, offset }, + variables: { title, genres, limit, offset }, }); return { books: data?.searchBooks.books.map(toBook) ?? [], diff --git a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx index e3651fe..e42c3e3 100644 --- a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx +++ b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx @@ -1,125 +1,71 @@ -import type { BookFilters } from "~/api/types"; -import { languageCodes } from "~/api/languageCodes"; -import { CircleQuestionMark } from "~/components/icons/CircleQuestionMark"; 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", - }, -]; +import { useGenres } from "~/api/useGenres"; +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, + useComboboxAnchor, +} from "~/components/ui/Combobox"; function FilterBooksSection() { const [, setSearchParams] = useSearchParams(); - const { filters: rawFilters } = useBookSearchParams(); - - const [filters, setFilters] = useState(rawFilters); - const newFiltersFromInputRef = useRef(false); - - const debouncedFilters = useDebounce(filters, 500); - - 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 { filters } = useBookSearchParams(); + const { genres } = useGenres(); + const genresAnchor = useComboboxAnchor(); return (

Filter Books

-
    - {FILTER_INPUTS.map((input) => ( -
  • - - { - newFiltersFromInputRef.current = true; - setFilters({ ...filters, [input.name]: e.target.value }); - }} - /> -
  • - ))} +
    • -
diff --git a/frontend/app/components/SearchBar/SearchBar.tsx b/frontend/app/components/SearchBar/SearchBar.tsx index a4345d9..8e46b74 100644 --- a/frontend/app/components/SearchBar/SearchBar.tsx +++ b/frontend/app/components/SearchBar/SearchBar.tsx @@ -8,7 +8,7 @@ import { useBookSearchParams } from "~/api/useBookSearchParams"; const SEARCH_DEBOUNCE_MS = 500; function SearchBar() { - const { rawQuery: query, filters } = useBookSearchParams(); + const { query, filters } = useBookSearchParams(); const [, setSearchParams] = useSearchParams(); const [inputField, setInputField] = useState(query); diff --git a/frontend/app/routes/book/selectedBook.tsx b/frontend/app/routes/book/selectedBook.tsx index ddc7879..050ba57 100644 --- a/frontend/app/routes/book/selectedBook.tsx +++ b/frontend/app/routes/book/selectedBook.tsx @@ -8,12 +8,13 @@ import { useBookSearchParams } from "~/api/useBookSearchParams"; 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.genres ?? [], SEARCH_LIMIT, (page - 1) * SEARCH_LIMIT, ); diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx index 76983a4..e333cdd 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.genres ?? [], SEARCH_LIMIT, (page - 1) * SEARCH_LIMIT, ); From 1d3ecfa267d65f48e155d82a6c7542d5203534d6 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 22:41:52 +0200 Subject: [PATCH 09/24] chore: generate GraphQL types --- frontend/app/api/types/graphql.generated.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index 905db23..4c5194c 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -36,8 +36,14 @@ export type RemoveFavoriteBookMutationVariables = Exact<{ export type RemoveFavoriteBookMutation = { removeFavoriteBook: boolean }; +export type GenresQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GenresQuery = { genres: Array }; + export type SearchBooksQueryVariables = Exact<{ title?: string | null | undefined; + genres?: Array | string | null | undefined; limit?: number | null | undefined; offset?: number | null | undefined; }>; From 41b862aa3fa4a874b9482507cb09fcb8b7e1afc5 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 22:46:20 +0200 Subject: [PATCH 10/24] feat: sort genres by popularity --- backend/src/schema/books/resolvers/Query/genres.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/src/schema/books/resolvers/Query/genres.ts b/backend/src/schema/books/resolvers/Query/genres.ts index cacee00..cfadd24 100644 --- a/backend/src/schema/books/resolvers/Query/genres.ts +++ b/backend/src/schema/books/resolvers/Query/genres.ts @@ -1,17 +1,20 @@ -import { asc } from "drizzle-orm"; +import { asc, count, desc, eq } from "drizzle-orm"; import type { QueryResolvers } from "~/schema/types.generated"; import { db } from "~/db"; -import { bookGenres } from "~/db/schema"; +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) - .orderBy(asc(bookGenres.genre)); + .leftJoin(bookToGenres, eq(bookToGenres.genreId, bookGenres.id)) + .groupBy(bookGenres.id) + .orderBy(desc(count(bookToGenres.bookId)), asc(bookGenres.genre)); return rows.map((row) => row.genre); }; From c0ecc5922508ec47f62bec1abf4ef2bdc0a958c5 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 22:48:50 +0200 Subject: [PATCH 11/24] test: add unit tests for genre filtering --- .../FilterBooksSection.test.tsx | 56 +++++++++++++++++++ .../components/SearchBar/SearchBar.test.tsx | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx diff --git a/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx b/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx new file mode 100644 index 0000000..515f264 --- /dev/null +++ b/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createRoutesStub, useSearchParams } from "react-router"; +import { FilterBooksSection } from "~/components/FilterBooksSection/FilterBooksSection"; + +vi.mock("~/api/useGenres", () => ({ + useGenres: () => ({ + genres: ["Fantasy", "Romance", "Science Fiction"], + loading: false, + error: undefined, + }), +})); + +// Shows the current "genre" params so tests can assert what was written to the URL. +function SearchParamProbe() { + const [searchParams] = useSearchParams(); + return

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

; +} + +const Stub = createRoutesStub([ + { + path: "/", + Component: () => ( + <> + + + + ), + }, +]); + +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", + ), + ); + }); +}); diff --git a/frontend/app/components/SearchBar/SearchBar.test.tsx b/frontend/app/components/SearchBar/SearchBar.test.tsx index 157b639..c534de9 100644 --- a/frontend/app/components/SearchBar/SearchBar.test.tsx +++ b/frontend/app/components/SearchBar/SearchBar.test.tsx @@ -51,7 +51,7 @@ describe("SearchBar", () => { }); it("shows the number of active filters", () => { - render(); + render(); expect( screen.getByRole("button", { name: "Filter books" }), From 610864a800261b8cbc6440e37032ddcc58cb5731 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 23:00:58 +0200 Subject: [PATCH 12/24] feat: add languages to GraphQL schema --- .../schema/books/resolvers/Query/languages.ts | 20 +++++++++++++++++++ backend/src/schema/books/schema.graphql | 1 + backend/src/schema/resolvers.generated.ts | 3 ++- backend/src/schema/schema.generated.graphqls | 1 + backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 2 ++ 6 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 backend/src/schema/books/resolvers/Query/languages.ts 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/schema.graphql b/backend/src/schema/books/schema.graphql index 0d5b108..82aa344 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -18,6 +18,7 @@ type Book { extend type Query { book(id: Int!): Book genres: [String!]! + languages: [String!]! searchBooks( title: String diff --git a/backend/src/schema/resolvers.generated.ts b/backend/src/schema/resolvers.generated.ts index eefa8eb..3484886 100644 --- a/backend/src/schema/resolvers.generated.ts +++ b/backend/src/schema/resolvers.generated.ts @@ -5,13 +5,14 @@ import { favoriteBookIds as Query_favoriteBookIds } from './favorites/resolve 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,genres: Query_genres,searchBooks: Query_searchBooks }, + Query: { book: Query_book,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 1518db7..b168ed9 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -39,5 +39,6 @@ type Query { """The logged-in user's most common genres among their favorite books.""" favoriteGenres(limit: Int = 3): [String!]! genres: [String!]! + languages: [String!]! searchBooks(author: String, bookFormat: String, genres: [String!], language: String, limit: Int = 12, 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 5f3a4a2..4677659 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":"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":"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":"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":"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":"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":"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":"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 diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts index 6df5338..5debc4e 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -64,6 +64,7 @@ export type Query = { /** The logged-in user's most common genres among their favorite books. */ favoriteGenres: Array; genres: Array; + languages: Array; searchBooks: BookSearchResult; }; @@ -222,6 +223,7 @@ export type QueryResolvers>; favoriteGenres?: Resolver, ParentType, ContextType, RequireFields>; genres?: Resolver, ParentType, ContextType>; + languages?: Resolver, ParentType, ContextType>; searchBooks?: Resolver>; }; From 6458c3a2bcfd2b426f328aeb948dbb7b6fbd8d4f Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 23:05:15 +0200 Subject: [PATCH 13/24] feat: add language filtering to FilterBooksSection --- .../books/resolvers/Query/searchBooks.ts | 3 +- frontend/app/api/types/graphql.generated.ts | 11 ++--- frontend/app/api/types/index.ts | 1 + frontend/app/api/useBookFilters.ts | 26 ++++++++++++ frontend/app/api/useBookSearchParams.ts | 1 + frontend/app/api/useGenres.ts | 17 -------- frontend/app/api/useSearchBooks.ts | 13 +++++- .../FilterBooksSection/FilterBooksSection.tsx | 41 ++++++++++++++++++- frontend/app/routes/book/selectedBook.tsx | 2 +- frontend/app/routes/home.tsx | 2 +- 10 files changed, 87 insertions(+), 30 deletions(-) create mode 100644 frontend/app/api/useBookFilters.ts delete mode 100644 frontend/app/api/useGenres.ts diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index 00dc66c..c38449d 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -13,8 +13,7 @@ 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.language) conditions.push(eq(books.language, args.language)); if (args.bookFormat) conditions.push(ilike(books.bookFormat, `%${args.bookFormat}%`)); if (args.publisher) diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index 4c5194c..523cb9d 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 }; + export type FavoriteBooksQueryVariables = Exact<{ limit?: number | null | undefined; offset?: number | null | undefined; @@ -36,14 +41,10 @@ export type RemoveFavoriteBookMutationVariables = Exact<{ export type RemoveFavoriteBookMutation = { removeFavoriteBook: boolean }; -export type GenresQueryVariables = Exact<{ [key: string]: never; }>; - - -export type GenresQuery = { genres: Array }; - export type SearchBooksQueryVariables = Exact<{ title?: string | null | undefined; genres?: Array | string | null | undefined; + language?: string | 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 7480b97..e1ca815 100644 --- a/frontend/app/api/types/index.ts +++ b/frontend/app/api/types/index.ts @@ -8,4 +8,5 @@ export interface Book { export interface BookFilters { genres?: string[]; + language?: string; } diff --git a/frontend/app/api/useBookFilters.ts b/frontend/app/api/useBookFilters.ts new file mode 100644 index 0000000..48e90fe --- /dev/null +++ b/frontend/app/api/useBookFilters.ts @@ -0,0 +1,26 @@ +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 + } +`; + +export const useBookFilters = () => { + const { data, loading, error } = useQuery(BOOK_FILTERS); + return { + genres: data?.genres ?? [], + languages: data?.languages ?? [], + loading, + error, + }; +}; diff --git a/frontend/app/api/useBookSearchParams.ts b/frontend/app/api/useBookSearchParams.ts index b5f8ed4..6f61d1a 100644 --- a/frontend/app/api/useBookSearchParams.ts +++ b/frontend/app/api/useBookSearchParams.ts @@ -13,6 +13,7 @@ function useBookSearchParams() { const genres = searchParams.getAll("genre"); return { genres: genres.length > 0 ? genres : undefined, + language: searchParams.get("language") ?? undefined, }; }, [searchParams]); diff --git a/frontend/app/api/useGenres.ts b/frontend/app/api/useGenres.ts deleted file mode 100644 index d6f9a3e..0000000 --- a/frontend/app/api/useGenres.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { gql, type TypedDocumentNode } from "@apollo/client"; -import { useQuery } from "@apollo/client/react"; -import type { - GenresQuery, - GenresQueryVariables, -} from "~/api/types/graphql.generated"; - -const GENRES: TypedDocumentNode = gql` - query Genres { - genres - } -`; - -export const useGenres = () => { - const { data, loading, error } = useQuery(GENRES); - return { genres: data?.genres ?? [], loading, error }; -}; diff --git a/frontend/app/api/useSearchBooks.ts b/frontend/app/api/useSearchBooks.ts index 5a91192..fb9f185 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, @@ -15,12 +16,14 @@ const SEARCH_BOOKS: TypedDocumentNode< query SearchBooks( $title: String $genres: [String!] + $language: String $limit: Int $offset: Int ) { searchBooks( title: $title genres: $genres + language: $language limit: $limit offset: $offset ) { @@ -38,12 +41,18 @@ const SEARCH_BOOKS: TypedDocumentNode< export const useSearchBooks = ( title: string, - genres: string[], + filters: BookFilters, limit: number, offset: number, ) => { const { data, loading, error } = useQuery(SEARCH_BOOKS, { - variables: { title, genres, limit, offset }, + variables: { + title, + genres: filters.genres, + language: filters.language, + limit, + offset, + }, }); return { books: data?.searchBooks.books.map(toBook) ?? [], diff --git a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx index e42c3e3..583cd03 100644 --- a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx +++ b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx @@ -1,6 +1,6 @@ import { useSearchParams } from "react-router"; import { useBookSearchParams } from "~/api/useBookSearchParams"; -import { useGenres } from "~/api/useGenres"; +import { useBookFilters } from "~/api/useBookFilters"; import { Combobox, ComboboxChip, @@ -8,6 +8,7 @@ import { ComboboxChipsInput, ComboboxContent, ComboboxEmpty, + ComboboxInput, ComboboxItem, ComboboxList, ComboboxValue, @@ -17,7 +18,7 @@ import { function FilterBooksSection() { const [, setSearchParams] = useSearchParams(); const { filters } = useBookSearchParams(); - const { genres } = useGenres(); + const { genres, languages } = useBookFilters(); const genresAnchor = useComboboxAnchor(); return ( @@ -67,6 +68,42 @@ function FilterBooksSection() { +
  • + + { + setSearchParams((searchParams) => { + if (language) { + searchParams.set("language", language); + } else { + searchParams.delete("language"); + } + return searchParams; + }); + }} + > + + + No languages found. + + {(language: string) => ( + + {language} + + )} + + + +
  • ); diff --git a/frontend/app/routes/book/selectedBook.tsx b/frontend/app/routes/book/selectedBook.tsx index 050ba57..7e1e81b 100644 --- a/frontend/app/routes/book/selectedBook.tsx +++ b/frontend/app/routes/book/selectedBook.tsx @@ -14,7 +14,7 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) { const { books } = useSearchBooks( query, - filters.genres ?? [], + filters, SEARCH_LIMIT, (page - 1) * SEARCH_LIMIT, ); diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx index e333cdd..e41d022 100644 --- a/frontend/app/routes/home.tsx +++ b/frontend/app/routes/home.tsx @@ -16,7 +16,7 @@ export default function HomePage() { const { books, totalCount, loading, error } = useSearchBooks( query, - filters.genres ?? [], + filters, SEARCH_LIMIT, (page - 1) * SEARCH_LIMIT, ); From 51362e68684ff21b43e57822d307087aca41dec0 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 23:06:43 +0200 Subject: [PATCH 14/24] test: check language filter usage and URL synchronization --- .../FilterBooksSection.test.tsx | 32 ++++++++++++++++--- .../components/SearchBar/SearchBar.test.tsx | 2 +- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx b/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx index 515f264..bbce36d 100644 --- a/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx +++ b/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx @@ -4,18 +4,24 @@ import userEvent from "@testing-library/user-event"; import { createRoutesStub, useSearchParams } from "react-router"; import { FilterBooksSection } from "~/components/FilterBooksSection/FilterBooksSection"; -vi.mock("~/api/useGenres", () => ({ - useGenres: () => ({ +vi.mock("~/api/useBookFilters", () => ({ + useBookFilters: () => ({ genres: ["Fantasy", "Romance", "Science Fiction"], + languages: ["English", "French", "Spanish"], loading: false, error: undefined, }), })); -// Shows the current "genre" params so tests can assert what was written to the URL. +// 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(",")}

    ; + return ( + <> +

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

    +

    {searchParams.get("language")}

    + + ); } const Stub = createRoutesStub([ @@ -53,4 +59,22 @@ describe("FilterBooksSection", () => { ), ); }); + + 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"), + ); + }); }); diff --git a/frontend/app/components/SearchBar/SearchBar.test.tsx b/frontend/app/components/SearchBar/SearchBar.test.tsx index c534de9..1841a10 100644 --- a/frontend/app/components/SearchBar/SearchBar.test.tsx +++ b/frontend/app/components/SearchBar/SearchBar.test.tsx @@ -51,7 +51,7 @@ describe("SearchBar", () => { }); it("shows the number of active filters", () => { - render(); + render(); expect( screen.getByRole("button", { name: "Filter books" }), From f5116620c67a9bd175ab8af9c379732e4c58905a Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 20 Oct 2026 23:46:55 +0200 Subject: [PATCH 15/24] feat: add Silder component from Shadcn --- frontend/app/components/ui/Slider.tsx | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 frontend/app/components/ui/Slider.tsx 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 }; From 8ea7baa17beabe84f9bcafda175253ca88387b17 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 21 Oct 2026 00:38:29 +0200 Subject: [PATCH 16/24] feat: add filtering based on page count --- .../books/resolvers/Query/searchBooks.ts | 14 +- backend/src/schema/books/schema.graphql | 2 + backend/src/schema/schema.generated.graphqls | 2 +- backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 2 + frontend/app/api/types/graphql.generated.ts | 2 + frontend/app/api/types/index.ts | 1 + frontend/app/api/useBookSearchParams.ts | 11 ++ frontend/app/api/useSearchBooks.ts | 6 + .../FilterBooksSection/FilterBooksSection.tsx | 4 + .../components/PagesFilter/PagesFilter.tsx | 130 ++++++++++++++++++ 11 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 frontend/app/components/PagesFilter/PagesFilter.tsx diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index c38449d..97f4abe 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -1,4 +1,14 @@ -import { and, asc, eq, exists, ilike, or, type SQL } from "drizzle-orm"; +import { + and, + asc, + eq, + exists, + gte, + ilike, + lte, + or, + type SQL, +} from "drizzle-orm"; import { db } from "~/db"; import { bookGenres, books, bookToGenres } from "~/db/schema"; import type { QueryResolvers } from "~/schema/types.generated"; @@ -18,6 +28,8 @@ export const searchBooks: NonNullable = async ( conditions.push(ilike(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[] = []; diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index 82aa344..2b106a4 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -28,6 +28,8 @@ extend type Query { bookFormat: String publisher: String genres: [String!] + minPages: Int + maxPages: Int limit: Int = 12 offset: Int = 0 ): BookSearchResult! diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index b168ed9..4924c3c 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -40,5 +40,5 @@ type Query { favoriteGenres(limit: Int = 3): [String!]! genres: [String!]! languages: [String!]! - searchBooks(author: String, bookFormat: String, genres: [String!], language: String, limit: Int = 12, offset: Int = 0, publisher: String, series: String, title: String): BookSearchResult! + 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 4677659..58675c4 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":"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":"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":"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":"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":"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":"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 diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts index 5debc4e..218215f 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -91,6 +91,8 @@ export type QuerysearchBooksArgs = { genres?: InputMaybe>; language?: InputMaybe; limit?: InputMaybe; + maxPages?: InputMaybe; + minPages?: InputMaybe; offset?: InputMaybe; publisher?: InputMaybe; series?: InputMaybe; diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index 523cb9d..0a5a170 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -45,6 +45,8 @@ export type SearchBooksQueryVariables = Exact<{ title?: string | null | undefined; genres?: Array | string | null | undefined; language?: 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 e1ca815..b9fb7a4 100644 --- a/frontend/app/api/types/index.ts +++ b/frontend/app/api/types/index.ts @@ -9,4 +9,5 @@ export interface Book { export interface BookFilters { genres?: string[]; language?: string; + pages?: { min?: number; max?: number }; } diff --git a/frontend/app/api/useBookSearchParams.ts b/frontend/app/api/useBookSearchParams.ts index 6f61d1a..dc4ec8b 100644 --- a/frontend/app/api/useBookSearchParams.ts +++ b/frontend/app/api/useBookSearchParams.ts @@ -2,6 +2,11 @@ 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(); @@ -11,9 +16,15 @@ function useBookSearchParams() { // 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, + pages: + minPages === undefined && maxPages === undefined + ? undefined + : { min: minPages, max: maxPages }, }; }, [searchParams]); diff --git a/frontend/app/api/useSearchBooks.ts b/frontend/app/api/useSearchBooks.ts index fb9f185..b29e812 100644 --- a/frontend/app/api/useSearchBooks.ts +++ b/frontend/app/api/useSearchBooks.ts @@ -17,6 +17,8 @@ const SEARCH_BOOKS: TypedDocumentNode< $title: String $genres: [String!] $language: String + $minPages: Int + $maxPages: Int $limit: Int $offset: Int ) { @@ -24,6 +26,8 @@ const SEARCH_BOOKS: TypedDocumentNode< title: $title genres: $genres language: $language + minPages: $minPages + maxPages: $maxPages limit: $limit offset: $offset ) { @@ -50,6 +54,8 @@ export const useSearchBooks = ( title, genres: filters.genres, language: filters.language, + minPages: filters.pages?.min, + maxPages: filters.pages?.max, limit, offset, }, diff --git a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx index 583cd03..ffadf20 100644 --- a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx +++ b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx @@ -14,6 +14,7 @@ import { ComboboxValue, useComboboxAnchor, } from "~/components/ui/Combobox"; +import { PagesFilter } from "~/components/PagesFilter/PagesFilter"; function FilterBooksSection() { const [, setSearchParams] = useSearchParams(); @@ -104,6 +105,9 @@ function FilterBooksSection() { +
  • + +
  • ); diff --git a/frontend/app/components/PagesFilter/PagesFilter.tsx b/frontend/app/components/PagesFilter/PagesFilter.tsx new file mode 100644 index 0000000..0cbad39 --- /dev/null +++ b/frontend/app/components/PagesFilter/PagesFilter.tsx @@ -0,0 +1,130 @@ +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"); + } + 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 ( +
    + + 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 }; From 455ef846d5edfd5c791b846ad1cf57ca2dfd19d1 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 21 Oct 2026 00:39:00 +0200 Subject: [PATCH 17/24] test: check pages filter counts as one filter --- frontend/app/components/SearchBar/SearchBar.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/app/components/SearchBar/SearchBar.test.tsx b/frontend/app/components/SearchBar/SearchBar.test.tsx index 1841a10..ce55b4c 100644 --- a/frontend/app/components/SearchBar/SearchBar.test.tsx +++ b/frontend/app/components/SearchBar/SearchBar.test.tsx @@ -58,4 +58,10 @@ describe("SearchBar", () => { ).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"); + }); }); From 885386b8ae24b15f9faec367beb57f9fd3ec99a0 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 21 Oct 2026 00:39:24 +0200 Subject: [PATCH 18/24] test: pages count filter and URL synchronization --- .../FilterBooksSection.test.tsx | 116 +++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx b/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx index bbce36d..0061fcd 100644 --- a/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx +++ b/frontend/app/components/FilterBooksSection/FilterBooksSection.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { createRoutesStub, useSearchParams } from "react-router"; +import { createRoutesStub, Link, useSearchParams } from "react-router"; import { FilterBooksSection } from "~/components/FilterBooksSection/FilterBooksSection"; vi.mock("~/api/useBookFilters", () => ({ @@ -20,6 +20,9 @@ function SearchParamProbe() { <>

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

    {searchParams.get("language")}

    +

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

    ); } @@ -31,6 +34,7 @@ const Stub = createRoutesStub([ <> + Go to 250+ pages ), }, @@ -77,4 +81,114 @@ describe("FilterBooksSection", () => { 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); + }); }); From 74516ec1b094efe230e878a3003f3887b063bcac Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 21 Oct 2026 21:18:46 +0200 Subject: [PATCH 19/24] fix: do not allow empty strings as values in book data We had a lot of books with their book format set to an empty string. We prefer the native SQL NULL type instead, so filtering works correctly --- backend/src/db/schema/books.ts | 2 +- backend/src/db/seed/index.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/src/db/schema/books.ts b/backend/src/db/schema/books.ts index 384e7b2..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"), diff --git a/backend/src/db/seed/index.ts b/backend/src/db/seed/index.ts index cdb87be..08ac4f7 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -21,7 +21,7 @@ type BookRow = { isbn: string; genres: string; characters: string[]; - bookFormat: string; + bookFormat: string | null; edition: string | null; pages: number | string; publisher: string | null; @@ -100,16 +100,16 @@ 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 == "[]") { From 85e1e2f85e51af8f2ff03aa0cefdd77ea5c78a63 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 21 Oct 2026 21:18:51 +0200 Subject: [PATCH 20/24] chore: run Prettier after GraphQL codegen --- backend/codegen.ts | 1 + 1 file changed, 1 insertion(+) 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; From bda467038c191ac11f2b1699e9da7baddb1b5255 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 21 Oct 2026 21:20:03 +0200 Subject: [PATCH 21/24] feat: add book formats query to backend --- .../books/resolvers/Query/bookFormats.ts | 20 +++++++++++++++++++ .../books/resolvers/Query/searchBooks.ts | 3 +-- backend/src/schema/books/schema.graphql | 3 ++- backend/src/schema/resolvers.generated.ts | 3 ++- backend/src/schema/schema.generated.graphqls | 3 ++- backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 6 ++++-- 7 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 backend/src/schema/books/resolvers/Query/bookFormats.ts 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/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index 97f4abe..23f1076 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -24,8 +24,7 @@ export const searchBooks: NonNullable = async ( 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(eq(books.language, args.language)); - if (args.bookFormat) - conditions.push(ilike(books.bookFormat, `%${args.bookFormat}%`)); + 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)); diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index 2b106a4..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 @@ -19,6 +19,7 @@ extend type Query { book(id: Int!): Book genres: [String!]! languages: [String!]! + bookFormats: [String!]! searchBooks( title: String diff --git a/backend/src/schema/resolvers.generated.ts b/backend/src/schema/resolvers.generated.ts index 3484886..9439ab8 100644 --- a/backend/src/schema/resolvers.generated.ts +++ b/backend/src/schema/resolvers.generated.ts @@ -1,6 +1,7 @@ /* 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'; @@ -12,7 +13,7 @@ import { removeFavoriteBook as Mutation_removeFavoriteBook } from './favorite 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,genres: Query_genres,languages: Query_languages,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 4924c3c..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. diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 58675c4..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":"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":"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":"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 218215f..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,6 +57,7 @@ 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. */ @@ -194,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>; @@ -221,6 +222,7 @@ export type MutationResolvers = { book?: Resolver, ParentType, ContextType, RequireFields>; + bookFormats?: Resolver, ParentType, ContextType>; favoriteBookIds?: Resolver, ParentType, ContextType>; favoriteBooks?: Resolver>; favoriteGenres?: Resolver, ParentType, ContextType, RequireFields>; From f7941eca0692fdf78d90508d6371bfadedd1c436 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 21 Oct 2026 21:20:55 +0200 Subject: [PATCH 22/24] feat: add book format filtering to book filters --- frontend/app/api/types/graphql.generated.ts | 3 +- frontend/app/api/types/index.ts | 1 + frontend/app/api/useBookFilters.ts | 2 + frontend/app/api/useBookSearchParams.ts | 1 + frontend/app/api/useSearchBooks.ts | 3 ++ .../FilterBooksSection/FilterBooksSection.tsx | 38 ++++++++++++++++++- 6 files changed, 46 insertions(+), 2 deletions(-) diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index 0a5a170..044a5fd 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -12,7 +12,7 @@ export type BookQuery = { book: { __typename: 'Book', id: number, title: string, export type BookFiltersQueryVariables = Exact<{ [key: string]: never; }>; -export type BookFiltersQuery = { genres: Array, languages: Array }; +export type BookFiltersQuery = { genres: Array, languages: Array, bookFormats: Array }; export type FavoriteBooksQueryVariables = Exact<{ limit?: number | null | undefined; @@ -45,6 +45,7 @@ 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; diff --git a/frontend/app/api/types/index.ts b/frontend/app/api/types/index.ts index b9fb7a4..7d517b8 100644 --- a/frontend/app/api/types/index.ts +++ b/frontend/app/api/types/index.ts @@ -9,5 +9,6 @@ export interface Book { export interface BookFilters { genres?: string[]; language?: string; + bookFormat?: string; pages?: { min?: number; max?: number }; } diff --git a/frontend/app/api/useBookFilters.ts b/frontend/app/api/useBookFilters.ts index 48e90fe..734b873 100644 --- a/frontend/app/api/useBookFilters.ts +++ b/frontend/app/api/useBookFilters.ts @@ -12,6 +12,7 @@ const BOOK_FILTERS: TypedDocumentNode< query BookFilters { genres languages + bookFormats } `; @@ -20,6 +21,7 @@ export const useBookFilters = () => { 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 dc4ec8b..520434a 100644 --- a/frontend/app/api/useBookSearchParams.ts +++ b/frontend/app/api/useBookSearchParams.ts @@ -21,6 +21,7 @@ function useBookSearchParams() { return { genres: genres.length > 0 ? genres : undefined, language: searchParams.get("language") ?? undefined, + bookFormat: searchParams.get("bookFormat") ?? undefined, pages: minPages === undefined && maxPages === undefined ? undefined diff --git a/frontend/app/api/useSearchBooks.ts b/frontend/app/api/useSearchBooks.ts index b29e812..5179808 100644 --- a/frontend/app/api/useSearchBooks.ts +++ b/frontend/app/api/useSearchBooks.ts @@ -17,6 +17,7 @@ const SEARCH_BOOKS: TypedDocumentNode< $title: String $genres: [String!] $language: String + $bookFormat: String $minPages: Int $maxPages: Int $limit: Int @@ -26,6 +27,7 @@ const SEARCH_BOOKS: TypedDocumentNode< title: $title genres: $genres language: $language + bookFormat: $bookFormat minPages: $minPages maxPages: $maxPages limit: $limit @@ -54,6 +56,7 @@ export const useSearchBooks = ( title, genres: filters.genres, language: filters.language, + bookFormat: filters.bookFormat, minPages: filters.pages?.min, maxPages: filters.pages?.max, limit, diff --git a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx index ffadf20..eb49d99 100644 --- a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx +++ b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx @@ -19,7 +19,7 @@ import { PagesFilter } from "~/components/PagesFilter/PagesFilter"; function FilterBooksSection() { const [, setSearchParams] = useSearchParams(); const { filters } = useBookSearchParams(); - const { genres, languages } = useBookFilters(); + const { genres, languages, bookFormats } = useBookFilters(); const genresAnchor = useComboboxAnchor(); return ( @@ -105,6 +105,42 @@ function FilterBooksSection() { +
  • + + { + setSearchParams((searchParams) => { + if (bookFormat) { + searchParams.set("bookFormat", bookFormat); + } else { + searchParams.delete("bookFormat"); + } + return searchParams; + }); + }} + > + + + No book formats found. + + {(bookFormat: string) => ( + + {bookFormat} + + )} + + + +
  • From 491e5f0623b55be6d568c7a076946352964b7651 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Fri, 23 Oct 2026 21:46:10 +0200 Subject: [PATCH 23/24] refactor: rename pages filter to book length Co-authored-by: Johannes Horn --- frontend/app/components/PagesFilter/PagesFilter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/components/PagesFilter/PagesFilter.tsx b/frontend/app/components/PagesFilter/PagesFilter.tsx index 0cbad39..0feccc4 100644 --- a/frontend/app/components/PagesFilter/PagesFilter.tsx +++ b/frontend/app/components/PagesFilter/PagesFilter.tsx @@ -59,7 +59,7 @@ function PagesFilter() { return (
    - Pages + Book length (pages) Date: Fri, 23 Oct 2026 21:55:53 +0200 Subject: [PATCH 24/24] fix: reset page search param when changing filters --- .../app/components/FilterBooksSection/FilterBooksSection.tsx | 3 +++ frontend/app/components/PagesFilter/PagesFilter.tsx | 1 + 2 files changed, 4 insertions(+) diff --git a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx index eb49d99..9842fd7 100644 --- a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx +++ b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx @@ -40,6 +40,7 @@ function FilterBooksSection() { selected.forEach((genre) => searchParams.append("genre", genre), ); + searchParams.delete("page"); return searchParams; }); }} @@ -83,6 +84,7 @@ function FilterBooksSection() { } else { searchParams.delete("language"); } + searchParams.delete("page"); return searchParams; }); }} @@ -119,6 +121,7 @@ function FilterBooksSection() { } else { searchParams.delete("bookFormat"); } + searchParams.delete("page"); return searchParams; }); }} diff --git a/frontend/app/components/PagesFilter/PagesFilter.tsx b/frontend/app/components/PagesFilter/PagesFilter.tsx index 0feccc4..d420e36 100644 --- a/frontend/app/components/PagesFilter/PagesFilter.tsx +++ b/frontend/app/components/PagesFilter/PagesFilter.tsx @@ -47,6 +47,7 @@ function PagesFilter() { } else { searchParams.delete("maxPages"); } + searchParams.delete("page"); return searchParams; }); }