diff --git a/AGENTS.md b/AGENTS.md index 8fee500..5a1a893 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ - **Name:** UFOund it - **What it is:** UFOund it, is a webapp that displays UFO sightings. - **Dataset:** [NUFORC UFO Sightings (Kaggle)](https://www.kaggle.com/datasets/NUFORC/ufo-sightings), CSV, approx 80,000 reports, approx 15mb. -- **Current state:** The frontend runs on hand-written mock data in `frontend/src/data/`. The backend is an Apollo Server with only a placeholder `health` query and is not yet connected to the frontend. No database yet. +- **Current state:** The frontend runs on hand-written mock data in `frontend/src/data/`. The backend is an Apollo Server with only a placeholder `health` query. The frontend reaches it through Apollo Client, but only for a temporary backend status check in the footer. No database yet. - **Course context:** NTNU IT2810 project. Must be reproducible, accessible and sustainable. ## Tech stack @@ -28,13 +28,18 @@ Run from the repository root unless noted. # Install dependencies npm install -# Start frontend dev server +# Start frontend dev server (proxies /project2/graphql to the backend) npm run dev # Start backend dev server (GraphQL on http://localhost:3001, restarts on save) -# Use PORT=3002 npm run dev:backend if port 3001 is taken npm run dev:backend +# If port 3001 is taken, move both to another port. The frontend's +# BACKEND_PORT must match the backend's PORT, or the footer shows +# "Backend: unavailable". +PORT=3002 npm run dev:backend +BACKEND_PORT=3002 npm run dev + # Build (type-check + production build of frontend and backend) npm run build @@ -59,13 +64,13 @@ npm run test ``` frontend/ src/ - components/ Reusable UI (Banner, SearchBar, SightingList, SightingMap) + components/ Reusable UI (Banner, SearchBar, SightingList, SightingMap, BackendStatus) pages/ Route-level components (SearchPage, SightingDetailPage) hooks/ Custom hooks (debounce, sighting search, single sighting) utils/ Pure helpers (filtering, formatting) types/ Shared TypeScript types data/ Mock sightings (to be replaced by GraphQL) - # graphql/ Planned: Apollo queries + graphql/ Apollo Client setup (client.ts) and GraphQL queries (queries.ts) backend/ Node + TypeScript GraphQL server (Apollo Server 5) src/ index.ts Starts the server on PORT (default 3001) @@ -127,6 +132,7 @@ Never: - **VM address:** TODO (`http://it2810-XX.idi.ntnu.no/project2`) - **Backend port:** 3001 +- **GraphQL endpoint:** The frontend calls `/project2/graphql` on its own origin. Apache must proxy it to `http://localhost:3001/graphql`, like the Vite dev proxy does locally. `VITE_GRAPHQL_URL` overrides the URL at build time if needed. - **How to deploy:** TODO ## AI usage documentation diff --git a/frontend/package.json b/frontend/package.json index 26173b5..2200f48 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,11 +11,14 @@ "test": "vitest" }, "dependencies": { + "@apollo/client": "^4.3.2", + "graphql": "^16.14.2", "leaflet": "^1.9.4", "react": "^19.2.8", "react-dom": "^19.2.8", "react-leaflet": "^5.0.0", - "react-router": "^8.4.0" + "react-router": "^8.4.0", + "rxjs": "^7.8.2" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67ff84d..468e4c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { Route, Routes } from 'react-router' +import BackendStatus from './components/BackendStatus' import Banner from './components/Banner' import SearchPage from './pages/SearchPage' import SightingDetailPage from './pages/SightingDetailPage' @@ -16,6 +17,9 @@ function App() { } /> + ) } diff --git a/frontend/src/components/BackendStatus.css b/frontend/src/components/BackendStatus.css new file mode 100644 index 0000000..69d3e87 --- /dev/null +++ b/frontend/src/components/BackendStatus.css @@ -0,0 +1,10 @@ +.backend-status { + padding: 16px 32px; + border-top: 1px solid var(--border); + color: var(--text); + font-size: 15px; + + @media (max-width: 1024px) { + padding: 16px; + } +} diff --git a/frontend/src/components/BackendStatus.tsx b/frontend/src/components/BackendStatus.tsx new file mode 100644 index 0000000..bf462ba --- /dev/null +++ b/frontend/src/components/BackendStatus.tsx @@ -0,0 +1,25 @@ +import { useQuery } from '@apollo/client/react' +import { HEALTH_QUERY } from '../graphql/queries' +import './BackendStatus.css' + +/** + * Temporary check that the frontend can reach the GraphQL backend. + * Remove once real queries show whether the backend is up. + */ +function BackendStatus() { + const { data, loading, error } = useQuery(HEALTH_QUERY) + + let status = 'checking…' + if (error) status = 'unavailable' + else if (!loading && data?.health === 'ok') status = 'connected' + + // role="status" makes screen readers announce the change from + // "checking…" to the result, without moving focus. + return ( +

+ Backend: {status} +

+ ) +} + +export default BackendStatus diff --git a/frontend/src/graphql/client.ts b/frontend/src/graphql/client.ts new file mode 100644 index 0000000..83108ae --- /dev/null +++ b/frontend/src/graphql/client.ts @@ -0,0 +1,12 @@ +import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client' + +// BASE_URL is Vite's `base` ("/project2"), so the default is a same-origin +// path that the Vite dev proxy and Apache on the VM both forward to the +// backend. VITE_GRAPHQL_URL is only for pointing at a different backend. +const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, '') +const graphqlUrl = import.meta.env.VITE_GRAPHQL_URL ?? `${baseUrl}/graphql` + +export const apolloClient = new ApolloClient({ + link: new HttpLink({ uri: graphqlUrl }), + cache: new InMemoryCache(), +}) diff --git a/frontend/src/graphql/queries.ts b/frontend/src/graphql/queries.ts new file mode 100644 index 0000000..535504b --- /dev/null +++ b/frontend/src/graphql/queries.ts @@ -0,0 +1,15 @@ +import { gql, type TypedDocumentNode } from '@apollo/client' + +interface HealthQueryData { + health: string +} + +/** + * Asks the backend's placeholder health field, to check that the frontend + * can reach it. The type parameters let useQuery know the shape of `data`. + */ +export const HEALTH_QUERY: TypedDocumentNode = gql` + query Health { + health + } +` diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b10eb49..b71cab8 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,15 +1,19 @@ +import { ApolloProvider } from '@apollo/client/react' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { HashRouter } from 'react-router' import './index.css' import App from './App.tsx' +import { apolloClient } from './graphql/client.ts' // Hash URLs (/project2/#/sightings/1) need no server rewrite rules, so // refreshing or sharing a deep link works on the course VM as-is. createRoot(document.getElementById('root')!).render( - - - + + + + + , ) diff --git a/frontend/src/tests/BackendStatus.test.tsx b/frontend/src/tests/BackendStatus.test.tsx new file mode 100644 index 0000000..5b81468 --- /dev/null +++ b/frontend/src/tests/BackendStatus.test.tsx @@ -0,0 +1,52 @@ +import { + MockedProvider, + type MockedProviderProps, +} from '@apollo/client/testing/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import BackendStatus from '../components/BackendStatus' +import { HEALTH_QUERY } from '../graphql/queries' + +// MockedProvider stands in for ApolloProvider and answers queries from the +// mocks below, so these tests never need a running backend. +function renderWithMocks(mocks: MockedProviderProps['mocks']) { + return render( + + + , + ) +} + +describe('BackendStatus', () => { + it('shows that it is checking while the query is in flight', () => { + renderWithMocks([ + { request: { query: HEALTH_QUERY }, result: { data: { health: 'ok' } } }, + ]) + + expect(screen.getByText('Backend: checking…')).toBeInTheDocument() + }) + + it('is a status region so screen readers announce changes', () => { + renderWithMocks([ + { request: { query: HEALTH_QUERY }, result: { data: { health: 'ok' } } }, + ]) + + expect(screen.getByRole('status')).toHaveTextContent('Backend: checking…') + }) + + it('shows connected when the backend answers ok', async () => { + renderWithMocks([ + { request: { query: HEALTH_QUERY }, result: { data: { health: 'ok' } } }, + ]) + + expect(await screen.findByText('Backend: connected')).toBeInTheDocument() + }) + + it('shows unavailable when the request fails', async () => { + renderWithMocks([ + { request: { query: HEALTH_QUERY }, error: new Error('Network error') }, + ]) + + expect(await screen.findByText('Backend: unavailable')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..c701047 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,8 @@ +/** + * Types for our own VITE_ environment variables. Vite inlines these into + * the bundle at build time, so they must never contain secrets. + */ +interface ImportMetaEnv { + /** Overrides the GraphQL endpoint. Defaults to /project2/graphql. */ + readonly VITE_GRAPHQL_URL?: string +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 37a303c..7a9e561 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,10 +1,25 @@ import react from '@vitejs/plugin-react' import { defineConfig } from 'vitest/config' +// Matches the backend's PORT, so `BACKEND_PORT=3002 npm run dev` works +// when the backend runs with `PORT=3002`. +const backendPort = process.env.BACKEND_PORT ?? '3001' + // https://vite.dev/config/ export default defineConfig({ plugins: [react()], base: '/project2', + server: { + // The frontend always calls /project2/graphql on its own origin. In + // development Vite forwards it to the backend, on the VM Apache does, + // so the browser never makes cross-origin (CORS) requests. + proxy: { + '/project2/graphql': { + target: `http://localhost:${backendPort}`, + rewrite: (path) => path.replace(/^\/project2/, ''), + }, + }, + }, test: { globals: true, environment: 'jsdom', diff --git a/package-lock.json b/package-lock.json index 9c6db43..df228aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,11 +33,14 @@ "frontend": { "version": "0.0.0", "dependencies": { + "@apollo/client": "^4.3.2", + "graphql": "^16.14.2", "leaflet": "^1.9.4", "react": "^19.2.8", "react-dom": "^19.2.8", "react-leaflet": "^5.0.0", - "react-router": "^8.4.0" + "react-router": "^8.4.0", + "rxjs": "^7.8.2" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -77,6 +80,43 @@ "graphql": "14.x || 15.x || 16.x" } }, + "node_modules/@apollo/client": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.2.tgz", + "integrity": "sha512-ra6f8NhP4b5Wznw6VpRGOGF6Q6a8rORToQeIx5BMC5Rc7mRVZ9wCN+01ynIZPjmlGPNif2Lqt0LRD+futfNgIw==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "optimism": "^0.18.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "graphql": "^16.0.0 || ^17.0.0", + "graphql-ws": "^5.5.5 || ^6.0.3", + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "rxjs": "^7.3.0", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, "node_modules/@apollo/protobufjs": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.8.tgz", @@ -2492,6 +2532,54 @@ "node": ">=22.15.0" } }, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -3652,6 +3740,21 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/graphql-tag": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -4565,6 +4668,18 @@ "node": ">= 0.8" } }, + "node_modules/optimism": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", + "integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==", + "license": "MIT", + "dependencies": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.5.0", + "tslib": "^2.3.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4950,6 +5065,15 @@ "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",