diff --git a/backend/src/index.ts b/backend/src/index.ts index 1a7054c..35861a0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,6 +26,116 @@ function readPaging(args: { limit?: number; offset?: number }) { // Flere enn ca. 300 prikker blir uleselig og tungt å tegne. const TASTE_MAP_MAX = 300; +/** + * ORDER BY for hver sortering i vinkatalogen. + * Klienten sender bare navnet (enum WineSort), og SQL-en hentes herfra, + * så brukerinput havner aldri direkte i spørringen. + * w.id til slutt gir stabil rekkefølge ved like verdier, så paginering + * ikke hopper over eller gjentar viner. Viner uten verdi havner sist. + */ +const WINE_ORDER = { + NAME_ASC: 'w.name ASC, w.id', + NAME_DESC: 'w.name DESC, w.id', + // Ved lik rating kommer vinene flest har vurdert først + RATING_DESC: 'w.avg_rating DESC NULLS LAST, w.rating_count DESC, w.id', + RATING_ASC: 'w.avg_rating ASC NULLS LAST, w.rating_count DESC, w.id', + ABV_DESC: 'w.abv DESC NULLS LAST, w.id', + ABV_ASC: 'w.abv ASC NULLS LAST, w.id', +} as const; + +type WineSort = keyof typeof WINE_ORDER; + +// Samme verdier som typene body_level og acidity_level i databasen. +// Ukjente verdier avvises her, ellers gir PostgreSQL en intern feil. +const BODY_LEVELS = [ + 'Very light-bodied', + 'Light-bodied', + 'Medium-bodied', + 'Full-bodied', + 'Very full-bodied', +]; +const ACIDITY_LEVELS = ['Low', 'Medium', 'High']; + +type WineFilter = { + type?: string | null; + country?: string | null; + region?: string | null; + grape?: string | null; + body?: string | null; + acidity?: string | null; + minRatingCount?: number | null; +}; + +function badInput(message: string) { + return new GraphQLError(message, { extensions: { code: 'BAD_USER_INPUT' } }); +} + +/** + * Lager WHERE-delen for vinkatalogen ut fra søk og filtre. + * Samme mønster som tasteWhere: verdiene sendes alltid som parametere. + * Både vinlisten og tellingen bruker denne, så totalCount stemmer med listen. + * Spørringen må ha w (wines) og wi (wineries) med, fordi søket ser på produsent. + */ +function catalogWhere(search: string | null, filter: WineFilter = {}) { + const where: string[] = []; + const params: unknown[] = []; + const add = (sql: string, value: unknown) => { + params.push(value); + where.push(sql.replaceAll('?', `$${params.length}`)); + }; + + if (search) { + // Treff i starten av et ord i vinnavnet eller produsentnavnet + add( + `( + w.name ILIKE ?::text || '%' + OR w.name ILIKE '% ' || ?::text || '%' + OR wi.name ILIKE ?::text || '%' + OR wi.name ILIKE '% ' || ?::text || '%' + )`, + search, + ); + } + if (filter.type) add('w.type = ?', filter.type); + // Landkoden ligger på wines, så landfilteret trenger ingen join + if (filter.country) add('w.country_code = ?', filter.country); + if (filter.region) { + const regionId = Number(filter.region); + if (!Number.isInteger(regionId)) throw badInput('region must be an id'); + add('w.region_id = ?', regionId); + } + if (filter.grape) { + add( + `EXISTS ( + SELECT 1 FROM wine_grapes wg + JOIN grapes g ON g.id = wg.grape_id + WHERE wg.wine_id = w.id AND g.name = ? + )`, + filter.grape, + ); + } + if (filter.body) { + if (!BODY_LEVELS.includes(filter.body)) throw badInput('unknown body'); + add('w.body = ?', filter.body); + } + if (filter.acidity) { + if (!ACIDITY_LEVELS.includes(filter.acidity)) { + throw badInput('unknown acidity'); + } + add('w.acidity = ?', filter.acidity); + } + // Gjør sortering på rating nyttig: uten dette kommer viner med + // én enkelt vurdering på 5.0 først. + if (filter.minRatingCount != null) { + if (!Number.isInteger(filter.minRatingCount) || filter.minRatingCount < 0) { + throw badInput('minRatingCount cannot be negative'); + } + add('w.rating_count >= ?', filter.minRatingCount); + } + + return { sql: where.join(' AND ') || 'TRUE', params }; +} + type TasteFilter = { type?: string | null; grape?: string | null; @@ -115,6 +225,47 @@ const typeDefs = `#graphql wineCount: Int! } + # Sortering i vinkatalogen + enum WineSort { + NAME_ASC + NAME_DESC + RATING_DESC + RATING_ASC + ABV_DESC + ABV_ASC + } + + # Filtre for vinkatalogen. Alle er valgfrie; det som mangler, filtreres ikke på. + input WineFilter { + type: String + # Landkode, f.eks. "FR" + country: String + region: ID + grape: String + body: String + acidity: String + # Bare viner med minst så mange vurderinger + minRatingCount: Int + } + + type Country { + code: String! + name: String! + } + + type Region { + id: ID! + name: String! + } + + # Verdiene til nedtrekksmenyene i filterpanelet i vinkatalogen. + # Fylde og syre har faste verdier og ligger i klienten. + type CatalogOptions { + types: [String!]! + countries: [Country!]! + grapes: [String!]! + } + # Filtre for Explore by taste. Alle er valgfrie; det som mangler, filtreres ikke på. input TasteFilter { type: String @@ -138,10 +289,17 @@ const typeDefs = `#graphql wines( search: String + filter: WineFilter + sort: WineSort = NAME_ASC limit: Int = 20 offset: Int = 0 ): WinePage! + catalogOptions: CatalogOptions! + + # Regionene i ett land, til regionfilteret + regions(country: String!): [Region!]! + wine(id: ID!): WineDetails foodCategories: [FoodCategory!]! @@ -165,10 +323,19 @@ const resolvers = { wines: async ( _: unknown, - args: { search?: string; limit?: number; offset?: number }, + args: { + search?: string; + filter?: WineFilter | null; + sort?: WineSort | null; + limit?: number; + offset?: number; + }, ) => { const { limit, offset } = readPaging(args); const search = args.search?.trim() || null; + const { sql, params } = catalogWhere(search, args.filter ?? {}); + // GraphQL godtar bare verdiene i enum WineSort, så oppslaget finner alltid noe + const orderBy = WINE_ORDER[args.sort ?? 'NAME_ASC']; const winesResult = await pool.query( ` @@ -176,30 +343,24 @@ const resolvers = { w.id, w.name, w.type, - w.abv, + w.abv::float AS abv, w.body, w.acidity, wi.name AS winery, r.name AS region, c.name AS country, - w.avg_rating AS "avgRating", + w.avg_rating::float AS "avgRating", w.rating_count AS "ratingCount" FROM wines w JOIN wineries wi ON w.winery_id = wi.id JOIN regions r ON w.region_id = r.id JOIN countries c ON w.country_code = c.code - WHERE ( - $1::text IS NULL - OR w.name ILIKE $1 || '%' - OR w.name ILIKE '% ' || $1 || '%' - OR wi.name ILIKE $1 || '%' - OR wi.name ILIKE '% ' || $1 || '%' - ) - ORDER BY w.name, w.id - LIMIT $2 - OFFSET $3 + WHERE ${sql} + ORDER BY ${orderBy} + LIMIT $${params.length + 1} + OFFSET $${params.length + 2} `, - [search, limit, offset], + [...params, limit, offset], ); const countResult = await pool.query( @@ -207,15 +368,9 @@ const resolvers = { SELECT COUNT(*) FROM wines w JOIN wineries wi ON w.winery_id = wi.id - WHERE ( - $1::text IS NULL - OR w.name ILIKE $1 || '%' - OR w.name ILIKE '% ' || $1 || '%' - OR wi.name ILIKE $1 || '%' - OR wi.name ILIKE '% ' || $1 || '%' - ) + WHERE ${sql} `, - [search], + params, ); return { @@ -224,6 +379,35 @@ const resolvers = { }; }, + catalogOptions: async () => { + // Alle druer som minst én vin har, ikke bare de vanligste som på + // Explore by taste, så filteret dekker hele datasettet. + const [types, countries, grapes] = await Promise.all([ + pool.query(`SELECT DISTINCT type FROM wines ORDER BY type`), + pool.query(`SELECT code, name FROM countries ORDER BY name`), + pool.query(` + SELECT g.name + FROM grapes g + WHERE EXISTS (SELECT 1 FROM wine_grapes wg WHERE wg.grape_id = g.id) + ORDER BY g.name + `), + ]); + + return { + types: types.rows.map((r) => r.type), + countries: countries.rows, + grapes: grapes.rows.map((r) => r.name), + }; + }, + + regions: async (_: unknown, args: { country: string }) => { + const result = await pool.query( + `SELECT id, name FROM regions WHERE country_code = $1 ORDER BY name`, + [args.country], + ); + return result.rows; + }, + wine: async (_: unknown, args: { id: string }) => { const wineId = Number(args.id); diff --git a/client/src/components/SortSelect.tsx b/client/src/components/SortSelect.tsx new file mode 100644 index 0000000..a4a95ef --- /dev/null +++ b/client/src/components/SortSelect.tsx @@ -0,0 +1,44 @@ +import type { WineSort } from '../types/wine'; + +const SORT_OPTIONS: { value: WineSort; label: string }[] = [ + { value: 'NAME_ASC', label: 'Name (A–Z)' }, + { value: 'NAME_DESC', label: 'Name (Z–A)' }, + { value: 'RATING_DESC', label: 'Rating (highest first)' }, + { value: 'RATING_ASC', label: 'Rating (lowest first)' }, + { value: 'ABV_DESC', label: 'Alcohol (highest first)' }, + { value: 'ABV_ASC', label: 'Alcohol (lowest first)' }, +]; + +type Props = { + value: WineSort; + onChange: (sort: WineSort) => void; +}; + +/** + * Dropdown for choosing the sort order of the wine catalog. + * + * Controlled component, like TasteFilters: the value lives in WineCatalog, + * which sends it to the backend. The sorting itself happens in the database, + * so it applies to all wines, not just the page that is shown. + * + * A native onChange(event.target.value as WineSort)} + > + {SORT_OPTIONS.map((option) => ( + + ))} + + + ); +} diff --git a/client/src/components/WineCatalog.css b/client/src/components/WineCatalog.css new file mode 100644 index 0000000..289e7e2 --- /dev/null +++ b/client/src/components/WineCatalog.css @@ -0,0 +1,85 @@ +/* Search and sort side by side, stacked on narrow screens */ +.catalog-controls { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: end; +} + +.catalog-field { + display: grid; + gap: 0.3rem; +} + +.catalog-field label { + font-size: 0.85rem; + font-weight: 600; +} + +/* Filter dropdowns in a grid: as many columns as fit the screen */ +.catalog-filters { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); + gap: 1rem; + align-items: end; + margin: 1.5rem 0 0; + padding: 1rem 1.25rem 1.25rem; + background: var(--color-panel); + border: 1px solid var(--color-border); + border-radius: 0.75rem; +} + +.catalog-filters legend { + padding: 0 0.25rem; + font-weight: 600; +} + +.catalog-field select:disabled { + color: var(--color-muted); + cursor: not-allowed; +} + +/* Checkbox and text on one line; the whole label is clickable */ +.catalog-check { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 2.75rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +.catalog-check input { + width: 1.25rem; + height: 1.25rem; + accent-color: var(--color-accent); +} + +.catalog-reset { + min-height: 2.75rem; + padding: 0.45rem 1rem; + font: inherit; + color: var(--color-accent); + background: transparent; + border: 2px solid var(--color-accent); + border-radius: 0.5rem; + cursor: pointer; +} + +.catalog-reset:hover { + color: var(--color-accent-ink); + background: var(--color-accent); +} + +.catalog-field input, +.catalog-field select { + /* At least 44 px high, so they are easy to hit on touch screens */ + min-height: 2.75rem; + padding: 0.45rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: 0.5rem; + background: var(--color-field); + color: var(--color-text); + font: inherit; +} diff --git a/client/src/components/WineCatalog.tsx b/client/src/components/WineCatalog.tsx index 9d60543..a360cc6 100644 --- a/client/src/components/WineCatalog.tsx +++ b/client/src/components/WineCatalog.tsx @@ -2,14 +2,41 @@ import { useEffect, useRef, useState } from 'react'; import { useQuery } from '@apollo/client/react'; import WineList from './WineList'; import Pagination from './Pagination'; +import SortSelect from './SortSelect'; +import WineFilters from './WineFilters'; import { + CATALOG_OPTIONS_QUERY, + REGIONS_QUERY, WINES_QUERY, + type CatalogOptionsData, + type RegionsData, + type WineFilterInput, type WinesData, type WinesVariables, } from '../graphql/queries'; +import { EMPTY_CATALOG_FILTER } from '../data/catalog'; +import { POPULAR_MIN_RATINGS } from '../data/taste'; +import type { CatalogFilter, WineSort } from '../types/wine'; +import './WineCatalog.css'; export const PAGE_SIZE = 20; +/** + * Turns the filter state into the GraphQL filter. + * Empty values become null, which the backend reads as "no filter". + */ +function toFilterInput(filter: CatalogFilter): WineFilterInput { + return { + type: filter.type || null, + country: filter.country || null, + region: filter.region || null, + grape: filter.grape || null, + body: filter.body || null, + acidity: filter.acidity || null, + minRatingCount: filter.popularOnly ? POPULAR_MIN_RATINGS : null, + }; +} + /** * Fetches wines from the GraphQL backend and passes them to WineList. * @@ -24,6 +51,8 @@ export default function WineCatalog() { const [page, setPage] = useState(1); const [searchInput, setSearchInput] = useState(''); const [search, setSearch] = useState(''); + const [sort, setSort] = useState('NAME_ASC'); + const [filter, setFilter] = useState(EMPTY_CATALOG_FILTER); const offset = (page - 1) * PAGE_SIZE; useEffect(() => { @@ -41,11 +70,20 @@ export default function WineCatalog() { >(WINES_QUERY, { variables: { search: search || null, + filter: toFilterInput(filter), + sort, offset, limit: PAGE_SIZE, }, }); + const options = useQuery(CATALOG_OPTIONS_QUERY); + // Regions are only fetched once a country is chosen + const regions = useQuery(REGIONS_QUERY, { + variables: { country: filter.country }, + skip: !filter.country, + }); + const shown = data ?? previousData; const pageChanged = useRef(false); @@ -62,6 +100,18 @@ export default function WineCatalog() { setPage(newPage); } + // A new sort order gives a new list, so start again from the first page + function changeSort(newSort: WineSort) { + setSort(newSort); + setPage(1); + } + + // New filters give a new list too + function changeFilter(newFilter: CatalogFilter) { + setFilter(newFilter); + setPage(1); + } + // role="status" and role="alert" make screen readers announce the messages if (error) { return

Could not load wines. Please try again later.

; @@ -74,18 +124,29 @@ export default function WineCatalog() { return ( // aria-busy tells screen readers the content is being updated
-
- - - setSearchInput(event.target.value)} - placeholder="For example Barolo" - /> +
+
+ + + setSearchInput(event.target.value)} + placeholder="For example Barolo" + /> +
+ +
+ + void; + /** Choices for the dropdowns. Undefined while they are loading. */ + options: CatalogOptionsData['catalogOptions'] | undefined; + /** Regions in the chosen country. Undefined when no country is chosen. */ + regions: RegionsData['regions'] | undefined; +}; + +/** + * The filter panel in the wine catalog. + * + * Controlled component, like TasteFilters: the values live in WineCatalog, + * which sends them to the backend. The filtering happens in the database, + * so it covers the whole dataset, not just the page that is shown. + * + *
and tell screen readers that the dropdowns belong + * together, and every set('type', e.target.value)} + > + + {options?.types.map((t) => ( + + ))} + +
+ +
+ + +
+ +
+ + {/* Disabled until a country is chosen, so the list stays short */} + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + + ); +} diff --git a/client/src/components/WineList.tsx b/client/src/components/WineList.tsx index afb6728..6db48a9 100644 --- a/client/src/components/WineList.tsx +++ b/client/src/components/WineList.tsx @@ -63,6 +63,17 @@ export default function WineList({ wines, totalCount, offset = 0 }: Props) { ? wine.avgRating.toFixed(1) : 'No ratings'} + +
Alcohol
+
+ {wine.abv != null ? `${wine.abv.toFixed(1)} %` : 'Unknown'} +
+ +
Body
+
{wine.body ?? 'Unknown'}
+ +
Acidity
+
{wine.acidity ?? 'Unknown'}
diff --git a/client/src/data/catalog.ts b/client/src/data/catalog.ts new file mode 100644 index 0000000..9fc4b56 --- /dev/null +++ b/client/src/data/catalog.ts @@ -0,0 +1,17 @@ +import type { CatalogFilter } from '../types/wine'; + +/** + * Constants for the wine catalog. + * They live in their own file (not in a component file) so that + * React Fast Refresh keeps working, the same reason as data/taste.ts. + */ + +export const EMPTY_CATALOG_FILTER: CatalogFilter = { + type: '', + country: '', + region: '', + grape: '', + body: '', + acidity: '', + popularOnly: false, +}; diff --git a/client/src/data/exampleWines.ts b/client/src/data/exampleWines.ts index c454650..a569be4 100644 --- a/client/src/data/exampleWines.ts +++ b/client/src/data/exampleWines.ts @@ -14,6 +14,9 @@ export const exampleWines: WineListItem[] = [ region: 'Piemonte', country: 'Italy', avgRating: 4.5, + abv: 14.5, + body: 'Full-bodied', + acidity: 'High', }, { id: '2', @@ -23,6 +26,9 @@ export const exampleWines: WineListItem[] = [ region: 'Bourgogne', country: 'France', avgRating: 4.1, + abv: 12.5, + body: 'Light-bodied', + acidity: 'High', }, { id: '3', @@ -32,6 +38,9 @@ export const exampleWines: WineListItem[] = [ region: 'Rioja', country: 'Spain', avgRating: 3.9, + abv: 13.5, + body: 'Full-bodied', + acidity: 'Medium', }, { id: '4', @@ -41,5 +50,8 @@ export const exampleWines: WineListItem[] = [ region: 'Mosel', country: 'Germany', avgRating: null, + abv: null, + body: null, + acidity: null, }, ]; diff --git a/client/src/graphql/queries.ts b/client/src/graphql/queries.ts index aa33aef..176909f 100644 --- a/client/src/graphql/queries.ts +++ b/client/src/graphql/queries.ts @@ -1,5 +1,10 @@ import { gql } from '@apollo/client'; -import type { TasteWine, WineListItem } from '../types/wine'; +import type { + CatalogFilter, + TasteWine, + WineListItem, + WineSort, +} from '../types/wine'; /** * GraphQL queries used by the client. @@ -16,8 +21,20 @@ import type { TasteWine, WineListItem } from '../types/wine'; * never load the whole dataset (scalability and sustainability). */ export const WINES_QUERY = gql` - query Wines($search: String, $offset: Int, $limit: Int) { - wines(search: $search, offset: $offset, limit: $limit) { + query Wines( + $search: String + $filter: WineFilter + $sort: WineSort + $offset: Int + $limit: Int + ) { + wines( + search: $search + filter: $filter + sort: $sort + offset: $offset + limit: $limit + ) { totalCount items { id @@ -27,6 +44,9 @@ export const WINES_QUERY = gql` region country avgRating + abv + body + acidity } } } @@ -36,12 +56,59 @@ export type WinesData = { wines: { totalCount: number; items: WineListItem[] }; }; +/** Filters as the backend expects them: null means "no filter". */ +export type WineFilterInput = { + [K in Exclude]: string | null; +} & { minRatingCount: number | null }; + export type WinesVariables = { search?: string | null; + filter?: WineFilterInput; + sort?: WineSort; offset?: number; limit?: number; }; +/** The choices in the filter dropdowns in the wine catalog. */ +export const CATALOG_OPTIONS_QUERY = gql` + query CatalogOptions { + catalogOptions { + types + countries { + code + name + } + grapes + } + } +`; + +export type CatalogOptionsData = { + catalogOptions: { + types: string[]; + countries: { code: string; name: string }[]; + grapes: string[]; + }; +}; + +/** + * The regions in one country, for the region filter. + * Fetched only when a country is chosen, since the full dataset has + * thousands of regions. + */ +export const REGIONS_QUERY = gql` + query Regions($country: String!) { + regions(country: $country) { + id + name + } + } +`; + +export type RegionsData = { + regions: { id: string; name: string }[]; +}; + /** * Wines for the Explore by taste map. * diff --git a/client/src/types/wine.ts b/client/src/types/wine.ts index 88feb4b..95c8dbb 100644 --- a/client/src/types/wine.ts +++ b/client/src/types/wine.ts @@ -12,8 +12,37 @@ export type WineListItem = { country: string; /** null when the wine has no ratings */ avgRating: number | null; + /** Alcohol in percent, null when unknown */ + abv: number | null; + /** null when X-Wines has no value for the wine */ + body: BodyLevel | null; + acidity: AcidityLevel | null; +}; + +/** + * Filters in the wine catalog. Empty strings mean "no filter". + * country is a country code (e.g. 'FR') and region a region id. + */ +export type CatalogFilter = { + type: string; + country: string; + region: string; + grape: string; + body: string; + acidity: string; + /** Only wines with at least POPULAR_MIN_RATINGS ratings */ + popularOnly: boolean; }; +/** Sort orders for the wine catalog. Matches enum WineSort in the backend. */ +export type WineSort = + | 'NAME_ASC' + | 'NAME_DESC' + | 'RATING_DESC' + | 'RATING_ASC' + | 'ABV_DESC' + | 'ABV_ASC'; + /** The body levels in the database (enum body_level), lightest first. */ export type BodyLevel = | 'Very light-bodied'