diff --git a/backend/src/db/schema/books.ts b/backend/src/db/schema/books.ts index d145da4..9a867e1 100644 --- a/backend/src/db/schema/books.ts +++ b/backend/src/db/schema/books.ts @@ -4,6 +4,7 @@ import { varchar, text, primaryKey, + real, } from "drizzle-orm/pg-core"; const books = pgTable("books", { @@ -20,6 +21,8 @@ const books = pgTable("books", { publisher: text("publisher"), publishDate: text("publish_date"), coverImgUrl: text("cover_img_url"), + rating: real("rating"), + numRatings: integer("num_ratings"), }); const bookGenres = pgTable("book_genres", { diff --git a/backend/src/db/seed/index.ts b/backend/src/db/seed/index.ts index 08ac4f7..704c907 100644 --- a/backend/src/db/seed/index.ts +++ b/backend/src/db/seed/index.ts @@ -3,6 +3,7 @@ import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { parse } from "csv-parse"; import { db } from "~/db"; +import { parseRating } from "~/db/seed/parseRatings"; import { bookGenres, books, @@ -110,6 +111,7 @@ for await (const book of parser as AsyncIterable) { publisher: book.publisher?.trim() || null, publishDate: book.publishDate?.trim() || null, coverImgUrl: book.coverImg?.trim() || null, + ...parseRating(book), }); if (book.genres == "[]") { diff --git a/backend/src/db/seed/parseRatings.ts b/backend/src/db/seed/parseRatings.ts new file mode 100644 index 0000000..26c8b12 --- /dev/null +++ b/backend/src/db/seed/parseRatings.ts @@ -0,0 +1,21 @@ +function parseRating({ + rating, + numRatings, +}: { + rating: string | number; + numRatings: string | number; +}) { + const count = Number.parseInt(String(numRatings), 10); + const average = Number.parseFloat(String(rating)); + + const hasRatings = + Number.isFinite(count) && count > 0 && Number.isFinite(average); + + // The dataset uses 0.0 for books without ratings. We store NULL instead, so unrated books are not mistaken for books with the worst rating. + return { + rating: hasRatings && average > 0 ? average : null, + numRatings: Number.isFinite(count) ? count : null, + }; +} + +export { parseRating }; diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index 23f1076..f4f5520 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -1,10 +1,12 @@ import { and, asc, + desc, eq, exists, gte, ilike, + isNull, lte, or, type SQL, @@ -54,12 +56,26 @@ export const searchBooks: NonNullable = async ( // can be used both for fetching one page and for counting all matches. const filter = and(...conditions); + const orderBy = + args.sortBy === "RATING" + ? [ + // If the book has no rating (isNull returns true), we want it to be sorted last. + // Otherwise, the DESC order would put books with no rating first + asc(isNull(books.rating)), + args.sortDirection === "DESC" + ? desc(books.rating) + : asc(books.rating), + desc(books.numRatings), + asc(books.id), + ] + : [asc(books.id)]; + const [foundBooks, totalCount] = await Promise.all([ db .select() .from(books) .where(filter) - .orderBy(asc(books.id)) + .orderBy(...orderBy) .limit(args.limit) .offset(args.offset), db.$count(books, filter), diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index e2c0979..82b7ff5 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -13,6 +13,18 @@ type Book { publishDate: String coverImgUrl: String genres: [String!]! + rating: Float + numRatings: Int +} + +enum BookSortField { + ID + RATING +} + +enum SortDirection { + ASC + DESC } extend type Query { @@ -33,6 +45,8 @@ extend type Query { maxPages: Int limit: Int = 12 offset: Int = 0 + sortBy: BookSortField = ID + sortDirection: SortDirection = ASC ): BookSearchResult! } diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index 4375f88..f1b4060 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -8,9 +8,11 @@ type Book { id: Int! isbn: String! language: String + numRatings: Int pages: Int! publishDate: String publisher: String + rating: Float series: String title: String! } @@ -20,6 +22,11 @@ type BookSearchResult { totalCount: Int! } +enum BookSortField { + ID + RATING +} + type Mutation { addFavoriteBook(bookId: Int!): Book! removeFavoriteBook(bookId: Int!): Boolean! @@ -41,5 +48,10 @@ type Query { favoriteGenres(limit: Int = 3): [String!]! genres: [String!]! languages: [String!]! - searchBooks(author: String, bookFormat: String, genres: [String!], language: String, limit: Int = 12, maxPages: Int, minPages: Int, offset: Int = 0, publisher: String, series: String, title: String): BookSearchResult! + searchBooks(author: String, bookFormat: String, genres: [String!], language: String, limit: Int = 12, maxPages: Int, minPages: Int, offset: Int = 0, publisher: String, series: String, sortBy: BookSortField = ID, sortDirection: SortDirection = ASC, title: String): BookSearchResult! +} + +enum SortDirection { + ASC + DESC } \ No newline at end of file diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 5604351..d88abf4 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":"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 + 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"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"SortDirection"}},"defaultValue":{"kind":"EnumValue","value":"ASC"}}],"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":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"numRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"BookSortField"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"SortDirection"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ASC"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"DESC"}}]},{"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 a8a9c60..710bcbd 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -4,6 +4,7 @@ import type { ApolloContext } from '~/index'; export type Maybe = T | null | undefined; export type InputMaybe = T | null | undefined; export type Omit = Pick>; +export type EnumResolverSignature = { [key in keyof T]?: AllowedValues }; export type RequireFields = Omit & { [P in K]-?: NonNullable }; /** All built-in and custom scalars, mapped to their actual values */ export type Scalars = { @@ -25,9 +26,11 @@ export type Book = { id: Scalars['Int']['output']; isbn: Scalars['String']['output']; language?: Maybe; + numRatings?: Maybe; pages: Scalars['Int']['output']; publishDate?: Maybe; publisher?: Maybe; + rating?: Maybe; series?: Maybe; title: Scalars['String']['output']; }; @@ -38,6 +41,10 @@ export type BookSearchResult = { totalCount: Scalars['Int']['output']; }; +export type BookSortField = + | 'ID' + | 'RATING'; + export type Mutation = { __typename?: 'Mutation'; addFavoriteBook: Book; @@ -97,9 +104,15 @@ export type QuerysearchBooksArgs = { offset?: InputMaybe; publisher?: InputMaybe; series?: InputMaybe; + sortBy?: InputMaybe; + sortDirection?: InputMaybe; title?: InputMaybe; }; +export type SortDirection = + | 'ASC' + | 'DESC'; + export type ResolverTypeWrapper = Promise | T; @@ -176,10 +189,13 @@ export type ResolversTypes = { Book: ResolverTypeWrapper; String: ResolverTypeWrapper; Int: ResolverTypeWrapper; + Float: ResolverTypeWrapper; BookSearchResult: ResolverTypeWrapper & { books: Array }>; + BookSortField: ResolverTypeWrapper<'ID' | 'RATING'>; Mutation: ResolverTypeWrapper>; Boolean: ResolverTypeWrapper; Query: ResolverTypeWrapper>; + SortDirection: ResolverTypeWrapper<'ASC' | 'DESC'>; }; /** Mapping between all available schema types and the resolvers parents */ @@ -187,6 +203,7 @@ export type ResolversParentTypes = { Book: BookMapper; String: Scalars['String']['output']; Int: Scalars['Int']['output']; + Float: Scalars['Float']['output']; BookSearchResult: Omit & { books: Array }; Mutation: Record; Boolean: Scalars['Boolean']['output']; @@ -203,9 +220,11 @@ export type BookResolvers; isbn?: Resolver; language?: Resolver, ParentType, ContextType>; + numRatings?: Resolver, ParentType, ContextType>; pages?: Resolver; publishDate?: Resolver, ParentType, ContextType>; publisher?: Resolver, ParentType, ContextType>; + rating?: Resolver, ParentType, ContextType>; series?: Resolver, ParentType, ContextType>; title?: Resolver; }; @@ -215,6 +234,8 @@ export type BookSearchResultResolvers; }; +export type BookSortFieldResolvers = EnumResolverSignature<{ ID?: any, RATING?: any }, ResolversTypes['BookSortField']>; + export type MutationResolvers = { addFavoriteBook?: Resolver>; removeFavoriteBook?: Resolver>; @@ -228,13 +249,17 @@ export type QueryResolvers, ParentType, ContextType, RequireFields>; genres?: Resolver, ParentType, ContextType>; languages?: Resolver, ParentType, ContextType>; - searchBooks?: Resolver>; + searchBooks?: Resolver>; }; +export type SortDirectionResolvers = EnumResolverSignature<{ ASC?: any, DESC?: any }, ResolversTypes['SortDirection']>; + export type Resolvers = { Book?: BookResolvers; BookSearchResult?: BookSearchResultResolvers; + BookSortField?: BookSortFieldResolvers; Mutation?: MutationResolvers; Query?: QueryResolvers; + SortDirection?: SortDirectionResolvers; }; diff --git a/frontend/app/api/bookSort.ts b/frontend/app/api/bookSort.ts new file mode 100644 index 0000000..6d76be1 --- /dev/null +++ b/frontend/app/api/bookSort.ts @@ -0,0 +1,26 @@ +// The sorting options users can choose between. The key is what we store in the +// URL (?sort=rating_desc), and the other fields are what we send to the API. +const BOOK_SORTS = { + rating_desc: { + label: "Rating: high to low", + sortBy: "RATING", + sortDirection: "DESC", + }, + rating_asc: { + label: "Rating: low to high", + sortBy: "RATING", + sortDirection: "ASC", + }, +} as const; + +type BookSortKey = keyof typeof BOOK_SORTS; + +// Returns undefined for missing or unknown values, so a hand-edited URL +// falls back to the default order instead of causing an error. +function parseBookSort(value: string | null): BookSortKey | undefined { + return value !== null && Object.hasOwn(BOOK_SORTS, value) + ? (value as BookSortKey) + : undefined; +} + +export { BOOK_SORTS, parseBookSort, type BookSortKey }; diff --git a/frontend/app/api/toBook.ts b/frontend/app/api/toBook.ts index ce41fd4..2a86846 100644 --- a/frontend/app/api/toBook.ts +++ b/frontend/app/api/toBook.ts @@ -29,5 +29,6 @@ export function toBook( author: [book.author], publishedYear: yearFromPublishDate(book.publishDate), coverImage: book.coverImgUrl ?? undefined, + rating: "rating" in book ? (book.rating ?? undefined) : undefined, }; } diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index 044a5fd..8389c24 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -2,6 +2,14 @@ type Exact = { [K in keyof T]: T[K] }; /** Internal type. DO NOT USE DIRECTLY. */ export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +export type BookSortField = + | 'ID' + | 'RATING'; + +export type SortDirection = + | 'ASC' + | 'DESC'; + export type BookQueryVariables = Exact<{ id: number; }>; @@ -48,9 +56,11 @@ export type SearchBooksQueryVariables = Exact<{ bookFormat?: string | null | undefined; minPages?: number | null | undefined; maxPages?: number | null | undefined; + sortBy?: BookSortField | null | undefined; + sortDirection?: SortDirection | null | undefined; limit?: number | null | undefined; offset?: number | null | undefined; }>; -export type SearchBooksQuery = { searchBooks: { __typename: 'BookSearchResult', totalCount: number, books: Array<{ __typename: 'Book', id: number, title: string, author: string, publishDate: string | null, coverImgUrl: string | null }> } }; +export type SearchBooksQuery = { searchBooks: { __typename: 'BookSearchResult', totalCount: number, books: Array<{ __typename: 'Book', id: number, title: string, author: string, publishDate: string | null, coverImgUrl: string | null, rating: number | null }> } }; diff --git a/frontend/app/api/types/index.ts b/frontend/app/api/types/index.ts index 7d517b8..fee2098 100644 --- a/frontend/app/api/types/index.ts +++ b/frontend/app/api/types/index.ts @@ -4,6 +4,7 @@ export interface Book { author: string[]; publishedYear?: number; coverImage?: string; + rating?: number; } export interface BookFilters { diff --git a/frontend/app/api/useBookSearchParams.ts b/frontend/app/api/useBookSearchParams.ts index 520434a..e93a201 100644 --- a/frontend/app/api/useBookSearchParams.ts +++ b/frontend/app/api/useBookSearchParams.ts @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useSearchParams } from "react-router"; import type { BookFilters } from "~/api/types"; +import { parseBookSort } from "~/api/bookSort"; function parsePageCount(value: string | null) { const count = Number(value); @@ -12,6 +13,7 @@ function useBookSearchParams() { const query = searchParams.get("q") ?? ""; const page = parseInt(searchParams.get("page") ?? "1", 10); + const sort = parseBookSort(searchParams.get("sort")); // Only update filters when searchParams change to avoid unnecessary re-renders const filters = useMemo((): BookFilters => { @@ -29,7 +31,7 @@ function useBookSearchParams() { }; }, [searchParams]); - return { query, page, filters }; + return { query, page, filters, sort }; } export { useBookSearchParams }; diff --git a/frontend/app/api/useSearchBooks.ts b/frontend/app/api/useSearchBooks.ts index 5179808..b2f536c 100644 --- a/frontend/app/api/useSearchBooks.ts +++ b/frontend/app/api/useSearchBooks.ts @@ -2,6 +2,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 { BOOK_SORTS, type BookSortKey } from "~/api/bookSort"; import type { SearchBooksQuery, SearchBooksQueryVariables, @@ -20,6 +21,8 @@ const SEARCH_BOOKS: TypedDocumentNode< $bookFormat: String $minPages: Int $maxPages: Int + $sortBy: BookSortField + $sortDirection: SortDirection $limit: Int $offset: Int ) { @@ -30,6 +33,8 @@ const SEARCH_BOOKS: TypedDocumentNode< bookFormat: $bookFormat minPages: $minPages maxPages: $maxPages + sortBy: $sortBy + sortDirection: $sortDirection limit: $limit offset: $offset ) { @@ -40,6 +45,7 @@ const SEARCH_BOOKS: TypedDocumentNode< author publishDate coverImgUrl + rating } } } @@ -50,6 +56,7 @@ export const useSearchBooks = ( filters: BookFilters, limit: number, offset: number, + sort?: BookSortKey, ) => { const { data, loading, error } = useQuery(SEARCH_BOOKS, { variables: { @@ -59,6 +66,8 @@ export const useSearchBooks = ( bookFormat: filters.bookFormat, minPages: filters.pages?.min, maxPages: filters.pages?.max, + sortBy: sort && BOOK_SORTS[sort].sortBy, + sortDirection: sort && BOOK_SORTS[sort].sortDirection, limit, offset, }, diff --git a/frontend/app/components/SearchBar/SearchBar.tsx b/frontend/app/components/SearchBar/SearchBar.tsx index ef330ba..9ec33c9 100644 --- a/frontend/app/components/SearchBar/SearchBar.tsx +++ b/frontend/app/components/SearchBar/SearchBar.tsx @@ -18,6 +18,7 @@ import { import { useSearchParams } from "react-router"; import { useDebounce } from "~/api/useDebounce"; import { useBookSearchParams } from "~/api/useBookSearchParams"; +import { SortSelect } from "~/components/SortSelect/SortSelect"; const SEARCH_DEBOUNCE_MS = 500; @@ -119,6 +120,7 @@ function SearchBar({ resultCount }: SearchBarProps) { + ); } diff --git a/frontend/app/components/SortSelect/SortSelect.tsx b/frontend/app/components/SortSelect/SortSelect.tsx new file mode 100644 index 0000000..9223ceb --- /dev/null +++ b/frontend/app/components/SortSelect/SortSelect.tsx @@ -0,0 +1,60 @@ +import { useSearchParams } from "react-router"; +import { BOOK_SORTS, parseBookSort } from "~/api/bookSort"; +import { useBookSearchParams } from "~/api/useBookSearchParams"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/Select"; + +const DEFAULT_SORT = "default"; + +function SortSelect() { + const [, setSearchParams] = useSearchParams(); + const { sort } = useBookSearchParams(); + + const items = [ + { value: DEFAULT_SORT, label: "Default order" }, + ...Object.entries(BOOK_SORTS).map(([key, { label }]) => ({ + value: key, + label, + })), + ]; + + return ( +
+ Sort by + +
+ ); +} + +export { SortSelect }; diff --git a/frontend/app/routes/book/selectedBook.tsx b/frontend/app/routes/book/selectedBook.tsx index 5c10ee4..05223c9 100644 --- a/frontend/app/routes/book/selectedBook.tsx +++ b/frontend/app/routes/book/selectedBook.tsx @@ -23,7 +23,7 @@ const DISABLED_STYLE = "pointer-events-none opacity-50"; export default function SelectedBookPage({ params }: Route.ComponentProps) { const [searchParams] = useSearchParams(); - const { query, page, filters } = useBookSearchParams(); + const { query, page, filters, sort } = useBookSearchParams(); const { book, loading, error, description } = useBook(params.bookId); @@ -32,6 +32,7 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) { filters, SEARCH_LIMIT, (page - 1) * SEARCH_LIMIT, + sort, ); if (loading) { diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx index 5cb1b22..f0039bf 100644 --- a/frontend/app/routes/home.tsx +++ b/frontend/app/routes/home.tsx @@ -12,13 +12,14 @@ export function meta() { } export default function HomePage() { - const { query, page, filters } = useBookSearchParams(); + const { query, page, filters, sort } = useBookSearchParams(); const { books, totalCount, loading, error } = useSearchBooks( query, filters, SEARCH_LIMIT, (page - 1) * SEARCH_LIMIT, + sort, ); if (loading) {