From 93f3b7f4c1f2d3d46b9a6b7d51af436c629ff282 Mon Sep 17 00:00:00 2001 From: dhn06 Date: Thu, 15 Oct 2026 12:30:06 +0200 Subject: [PATCH] feat(frontend): connect the frontend to the real GraphQL API Replaces frontend/src/mocks/recipes.ts with Apollo Client queries against the backend, so search, category/rating filtering, sorting and "Load more" paging all run server-side over the whole dataset, as required. - Apollo Client chosen as the GraphQL client; its cache also satisfies the assignment's state-management requirement for server data (Zustand, merged separately via #60, covers local Fridge UI state) - Vite dev proxy for /project2/graphql mirrors the Apache proxy in production, and honors VITE_GRAPHQL_URL like IngredientSearch.tsx does - Fixed a real bug found while wiring this up: RecipeFilter's category values were lowercase ("beef"), but the server does a case-sensitive array-contains match against Title Case data ("Beef") - every category filter would have silently returned zero results - "My Fridge" ingredient matching still happens client-side on the loaded page only; the backend has no ingredient filter yet (follow-up issue). The any-ingredient-matches semantics and "Back to Fridge" link are from #60, rebased on top of here together with their HomePage/test changes - Loading and error states, aria-live reads the server's totalCount Closes #58 AI usage: Claude Code wrote the implementation and tests in this commit, driven by a reviewed plan; manually verified end-to-end in a browser against a local backend/Postgres with the real dataset (search, category filter, pagination, detail page, Fridge flow), both before and after rebasing onto #60's merged ingredient-autocomplete/Fridge-persistence work. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 6 +- README.md | 23 +- frontend/package.json | 2 + frontend/src/App.test.tsx | 28 +- frontend/src/components/RecipeCard.tsx | 4 +- frontend/src/components/RecipeFilter.test.tsx | 8 +- frontend/src/components/RecipeFilter.tsx | 31 +-- frontend/src/graphql/client.ts | 13 + frontend/src/graphql/queries.ts | 94 +++++++ frontend/src/main.tsx | 10 +- frontend/src/mocks/recipes.ts | 103 ------- frontend/src/pages/FridgePage.test.tsx | 27 +- frontend/src/pages/HomePage.test.tsx | 252 ++++++++++++++++-- frontend/src/pages/HomePage.tsx | 109 +++++++- frontend/src/pages/RecipeDetailPage.test.tsx | 73 ++++- frontend/src/pages/RecipeDetailPage.tsx | 34 ++- frontend/src/styles/HomePage.css | 38 ++- frontend/src/utils/recipeFilters.ts | 8 +- frontend/vite.config.ts | 5 +- package-lock.json | 124 +++++++++ package.json | 3 + 21 files changed, 802 insertions(+), 193 deletions(-) create mode 100644 frontend/src/graphql/client.ts create mode 100644 frontend/src/graphql/queries.ts delete mode 100644 frontend/src/mocks/recipes.ts diff --git a/CLAUDE.md b/CLAUDE.md index c630133..66b12d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,10 +36,14 @@ Chosen: - Client state: Zustand with persist middleware for the fridge (`localStorage`) - Dataset / domain: recipes, built from two Kaggle datasets (Epicurious ratings + a recipe/image dataset for photos), see README.md "Data" +- GraphQL client: Apollo Client. Its normalized cache also satisfies the assignment's + state-management requirement for server data; Zustand (above) covers local UI state Still open (record the decision here and in README.md when made): -- GraphQL client, E2E tool, UI/component lib: TBD +- E2E tool, UI/component lib: TBD +- Server-side filtering for "My Fridge" ingredients (tracked in a follow-up issue); for now + it only matches within the page already loaded from the server, not the whole dataset ## Repo layout diff --git a/README.md b/README.md index 95db018..990e4de 100644 --- a/README.md +++ b/README.md @@ -13,23 +13,25 @@ Hosted on the group VM: http://it2810-13.idi.ntnu.no/project2/ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Home** (`/`) | A search field (debounced by 300 ms, or press Enter to search right away), filters for category and minimum rating, and the results as a grid of recipe cards. The result count is read out to screen readers when the filters change. | | **Recipe detail** (`/recipes/:id`) | Photo, rating, categories, nutrition per serving, ingredients and directions. Each recipe gets its own browser tab title. | -| **My Fridge** (`/fridge`) | Add the ingredients you have, then "Find recipes" shows the recipes that use all of them. | +| **My Fridge** (`/fridge`) | Add the ingredients you have (typo-tolerant autocomplete), then "Find recipes" shows recipes that use any of them; extra ingredients in the fridge don't exclude a match. | | **404** | Shown for any unknown route. | Search terms and filters are kept in the URL (`?q=&category=&rating=&ingredients=`), so the Back button and shared links bring you back to the same results. **Current status:** the backend (GraphQL API over PostgreSQL with full-text -search, filtering, sorting and paging) is working. The frontend still uses -mock data (`frontend/src/mocks/`) and filters it in the browser -(`frontend/src/utils/recipeFilters.ts`). Next step is to connect it to the -API, so that search, filtering and sorting run on the server over the whole -dataset. User-generated data (such as reviews or ratings) is not built yet. +search, filtering, sorting and paging) is running on the VM, and the +frontend is connected to it with Apollo Client (`frontend/src/graphql/`) - +search, category/rating filtering, sorting and "Load more" paging all run +on the server over the whole dataset. The exception is "My Fridge": the +backend has no ingredient filter yet, so it only matches within the page +already loaded (tracked as a follow-up issue). User-generated data (such as +reviews or ratings) is not built yet. ## Architecture ``` -Browser ── React + TypeScript (Vite), React Router +Browser ── React + TypeScript (Vite), React Router, Apollo Client │ served by Apache at /project2 on the VM │ GraphQL ▼ @@ -41,9 +43,10 @@ PostgreSQL 17: recipes table, tsvector full-text search, GIN/B-tree indexes - **Frontend** (`frontend/src/`): `pages/` holds one component per route, `components/` holds shared parts (Navbar, Footer, RecipeCard, - RecipeFilter), `styles/` has one CSS file per component, and `utils/` has - plain logic such as reading filters from the URL. Tests (`*.test.tsx`) sit - next to the code they test. + RecipeFilter), `styles/` has one CSS file per component, `graphql/` has + the Apollo Client instance and queries, and `utils/` has plain logic such + as reading filters from the URL. Tests (`*.test.tsx`) sit next to the + code they test. - **Backend** (`backend/src/`): `schema.ts` holds the GraphQL types and resolvers and builds parameterised SQL in `buildRecipeQuery`. `server.test.ts` tests the API against a stub database. diff --git a/frontend/package.json b/frontend/package.json index 2977531..447ec29 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ "typecheck": "tsc -b" }, "dependencies": { + "@apollo/client": "^4.3.3", + "graphql": "^16.14.2", "react": "^19.2.8", "react-dom": "^19.2.8", "react-router": "^8.4.0", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 01e6a8f..c41626b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,13 +1,35 @@ import { render, screen } from '@testing-library/react' +import { MockedProvider } from '@apollo/client/testing/react' import { MemoryRouter } from 'react-router' import { expect, test } from 'vitest' import App from './App' +import { RECIPES_QUERY } from './graphql/queries' + +const mocks = [ + { + request: { + query: RECIPES_QUERY, + variables: { + search: undefined, + filter: { categories: undefined, minRating: undefined }, + sort: undefined, + limit: 20, + offset: 0, + }, + }, + result: { + data: { recipes: { totalCount: 0, hasMore: false, items: [] } }, + }, + }, +] const renderAt = (path: string) => render( - - - , + + + + + , ) test('renders the home page heading', () => { diff --git a/frontend/src/components/RecipeCard.tsx b/frontend/src/components/RecipeCard.tsx index 3669afb..92eb6e5 100644 --- a/frontend/src/components/RecipeCard.tsx +++ b/frontend/src/components/RecipeCard.tsx @@ -1,9 +1,9 @@ import { Link } from 'react-router' -import type { Recipe } from '../types/recipe' +import type { RecipeListItem } from '../graphql/queries' import '../styles/RecipeCard.css' type RecipeCardProps = { - recipe: Recipe + recipe: RecipeListItem } export default function RecipeCard({ recipe }: RecipeCardProps) { diff --git a/frontend/src/components/RecipeFilter.test.tsx b/frontend/src/components/RecipeFilter.test.tsx index 50c9b06..6ecb41a 100644 --- a/frontend/src/components/RecipeFilter.test.tsx +++ b/frontend/src/components/RecipeFilter.test.tsx @@ -40,8 +40,8 @@ test('Enter searches immediately', () => { test('selects update the URL and can be cleared', () => { const category = screen.getByLabelText('Category') - fireEvent.change(category, { target: { value: 'vegetarian' } }) - expect(url()).toBe('?category=vegetarian') + fireEvent.change(category, { target: { value: 'Vegetarian' } }) + expect(url()).toBe('?category=Vegetarian') fireEvent.change(category, { target: { value: '' } }) expect(url()).toBe('') @@ -49,7 +49,7 @@ test('selects update the URL and can be cleared', () => { test('offers beef and other common bases as category filters', () => { const category = screen.getByLabelText('Category') - fireEvent.change(category, { target: { value: 'beef' } }) - expect(url()).toBe('?category=beef') + fireEvent.change(category, { target: { value: 'Beef' } }) + expect(url()).toBe('?category=Beef') expect(screen.getByRole('option', { name: 'Lamb' })).toBeInTheDocument() }) diff --git a/frontend/src/components/RecipeFilter.tsx b/frontend/src/components/RecipeFilter.tsx index 7e0e894..5c58742 100644 --- a/frontend/src/components/RecipeFilter.tsx +++ b/frontend/src/components/RecipeFilter.tsx @@ -69,21 +69,22 @@ export default function RecipeFilter() { onChange={(event) => setParam('category', event.target.value)} > - - {/* Common bases for a dish. Values match dataset category names - case-insensitively (see filterRecipes / buildRecipeQuery). */} - - - - - - - - - - - - + + {/* Common bases for a dish. Values must match the dataset's + category strings exactly: the server does an array-contains + match (buildRecipeQuery), not a case-insensitive search. */} + + + + + + + + + + + +