Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 backend is an Apollo Server with `sightings`, `sighting` and `createSighting`, backed by MySQL on the VM. The search page fetches sightings through Apollo Client, with search, sort and "load more" pagination done in the database. The detail page still looks sightings up in the hand-written mock data in `frontend/src/data/`. A temporary footer shows whether the backend is reachable.
- **Current state:** The backend is an Apollo Server with `sightings`, `sighting` and `createSighting`, backed by MySQL on the VM. The search and detail pages fetch sightings through Apollo Client, with search, sort and "load more" pagination done in the database. A temporary footer shows whether the backend is reachable.
- **Course context:** NTNU IT2810 project. Must be reproducible, accessible and sustainable.

## Tech stack
Expand Down Expand Up @@ -69,7 +69,7 @@ frontend/
hooks/ Custom hooks (debounce, sighting search, single sighting)
utils/ Pure helpers (formatting)
types/ Shared TypeScript types
data/ Mock sightings, still used by the detail page and as test fixtures
tests/ Vitest tests and the mock sightings they use as fixtures
graphql/ Apollo Client setup and pagination cache rules (client.ts), queries.ts and mutations.ts
backend/ Node + TypeScript GraphQL server (Apollo Server 5)
src/
Expand Down
24 changes: 0 additions & 24 deletions frontend/src/data/sighting-store.ts

This file was deleted.

27 changes: 27 additions & 0 deletions frontend/src/graphql/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,30 @@ export const SIGHTINGS_QUERY: TypedDocumentNode<
}
}
`

interface SightingQueryData {
/** Null if no sighting has the id. */
sighting: UfoSighting | null
}

/** A single sighting by id, for the detail page. */
export const SIGHTING_QUERY: TypedDocumentNode<
SightingQueryData,
{ id: string }
> = gql`
query Sighting($id: ID!) {
sighting(id: $id) {
id
datetime
city
state
country
shape
durationSeconds
comments
datePosted
latitude
longitude
}
}
`
23 changes: 16 additions & 7 deletions frontend/src/hooks/use-sighting.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import type { UfoSighting } from '../types/ufo-sighting'
import { useSightings } from './use-sightings'
import { useQuery } from '@apollo/client/react'
import { SIGHTING_QUERY } from '../graphql/queries'

/**
* Looks up a single sighting by id. Wrapped in a hook so the detail page
* won't need to change when this becomes a GraphQL query.
* Fetches one sighting. `sighting` is null once the load has finished and
* the backend has no sighting with that id, which is not an `error`.
*/
export function useSighting(id: string | undefined): UfoSighting | undefined {
const sightings = useSightings()
return sightings.find((sighting) => sighting.id === id)
export function useSighting(id: string) {
const { data, loading, error, refetch } = useQuery(SIGHTING_QUERY, {
variables: { id },
})

// A failed refetch is shown through `error`, so the rejected promise
// needs no handling of its own.
const retry = () => {
refetch().catch(() => {})
}

return { sighting: data?.sighting ?? null, loading, error, retry }
}
6 changes: 0 additions & 6 deletions frontend/src/hooks/use-sightings.ts

This file was deleted.

21 changes: 17 additions & 4 deletions frontend/src/pages/SightingDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@ function getFromSearch(state: unknown): string {
}

function SightingDetailPage() {
const { id } = useParams()
const sighting = useSighting(id)
const { id = '' } = useParams()
const { sighting, loading, error, retry } = useSighting(id)
const location = useLocation()
const backTo = { pathname: '/', search: getFromSearch(location.state) }
const title = sighting ? formatLocation(sighting) : 'Sighting not found'

let title = 'Sighting not found'
if (sighting) title = formatLocation(sighting)
else if (error) title = 'Could not load sighting'
else if (loading) title = 'Loading sighting…'

// Route changes keep the list's scroll position, which would open the
// detail page scrolled past its heading.
Expand All @@ -58,7 +62,16 @@ function SightingDetailPage() {
<div className="sighting-detail">
{backLink}
<h1>{title}</h1>
<p>There is no sighting with id “{id}”.</p>
{error ? (
<div role="alert">
<p>Check your connection and try again.</p>
<button type="button" onClick={retry}>
Try again
</button>
</div>
) : (
!loading && <p>There is no sighting with id “{id}”.</p>
)}
</div>
)
}
Expand Down
21 changes: 0 additions & 21 deletions frontend/src/pages/SubmitSightingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ import { CombinedGraphQLErrors } from '@apollo/client'
import { useMutation } from '@apollo/client/react'
import { useState, type FormEvent } from 'react'
import { Link } from 'react-router'
import { addSighting } from '../data/sighting-store'
import {
CREATE_SIGHTING_MUTATION,
type CreatedSighting,
type SightingFieldError,
} from '../graphql/mutations'
import LocationPicker from '../components/LocationPicker'
Expand Down Expand Up @@ -91,24 +89,6 @@ function serverFieldErrors(error: unknown): FormErrors | null {
return errors
}

// The API allows nulls for imported rows, but a submitted sighting always
// has every field except state, so the fallbacks are only for type safety.
function toUfoSighting(created: CreatedSighting) {
return {
id: created.id,
datetime: created.datetime ?? '',
city: created.city ?? '',
state: created.state ?? '',
country: created.country ?? '',
shape: created.shape ?? '',
durationSeconds: created.durationSeconds ?? 0,
comments: created.comments ?? '',
datePosted: created.datePosted ?? '',
latitude: created.latitude ?? 0,
longitude: created.longitude ?? 0,
}
}

function SubmitSightingPage() {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState<FormErrors>({})
Expand Down Expand Up @@ -152,7 +132,6 @@ function SubmitSightingPage() {
},
})
if (!data) throw new Error('createSighting returned no data')
addSighting(toUfoSighting(data.createSighting))
setSubmittedId(data.createSighting.id)
} catch (error) {
const fieldErrors = serverFieldErrors(error)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/tests/SearchPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { of, throwError } from 'rxjs'
import { describe, expect, it, vi } from 'vitest'
import { mockSightings } from '../data/mock-sightings'
import { mockSightings } from './mock-sightings'
import { createCache } from '../graphql/client'
import type { SightingsQueryVariables } from '../graphql/queries'
import SearchPage from '../pages/SearchPage'
Expand Down
74 changes: 61 additions & 13 deletions frontend/src/tests/SightingDetailPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { render, screen } from '@testing-library/react'
import {
MockedProvider,
type MockedProviderProps,
} from '@apollo/client/testing/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SIGHTING_QUERY } from '../graphql/queries'
import SightingDetailPage from '../pages/SightingDetailPage'
import { mockSightings } from './mock-sightings'

vi.mock('../components/SightingMap', () => ({
default: ({
Expand All @@ -19,13 +25,26 @@ vi.mock('../components/SightingMap', () => ({
),
}))

function renderDetailPage(path: string, state?: unknown) {
const found = {
request: { query: SIGHTING_QUERY, variables: { id: '1' } },
result: {
data: { sighting: { __typename: 'Sighting', ...mockSightings[0] } },
},
}

function renderDetailPage(
path: string,
mocks: MockedProviderProps['mocks'],
state?: unknown,
) {
return render(
<MemoryRouter initialEntries={[{ pathname: path, state }]}>
<Routes>
<Route path="/sightings/:id" element={<SightingDetailPage />} />
</Routes>
</MemoryRouter>,
<MockedProvider mocks={mocks}>
<MemoryRouter initialEntries={[{ pathname: path, state }]}>
<Routes>
<Route path="/sightings/:id" element={<SightingDetailPage />} />
</Routes>
</MemoryRouter>
</MockedProvider>,
)
}

Expand All @@ -34,12 +53,15 @@ describe('SightingDetailPage', () => {
vi.restoreAllMocks()
})

it('renders sighting details and preserves the originating search link', () => {
it('renders sighting details and preserves the originating search link', async () => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
renderDetailPage('/sightings/1', { fromSearch: '?q=San+Marcos' })
renderDetailPage('/sightings/1', [found], { fromSearch: '?q=San+Marcos' })

expect(
screen.getByRole('heading', { name: 'San Marcos, TX, US' }),
screen.getByRole('heading', { name: 'Loading sighting…' }),
).toBeInTheDocument()
expect(
await screen.findByRole('heading', { name: 'San Marcos, TX, US' }),
).toBeInTheDocument()
expect(screen.getByText('45 min')).toBeInTheDocument()
expect(screen.getByText(/This event took place/)).toBeInTheDocument()
Expand All @@ -54,12 +76,17 @@ describe('SightingDetailPage', () => {
expect(document.title).toBe('San Marcos, TX, US · UFOund it')
})

it('handles missing sightings and links back to the unfiltered list', () => {
it('handles unknown ids and links back to the unfiltered list', async () => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
renderDetailPage('/sightings/missing')
renderDetailPage('/sightings/missing', [
{
request: { query: SIGHTING_QUERY, variables: { id: 'missing' } },
result: { data: { sighting: null } },
},
])

expect(
screen.getByRole('heading', { name: 'Sighting not found' }),
await screen.findByRole('heading', { name: 'Sighting not found' }),
).toBeInTheDocument()
expect(
screen.getByText('There is no sighting with id “missing”.'),
Expand All @@ -70,4 +97,25 @@ describe('SightingDetailPage', () => {
expect(screen.queryByTestId('detail-map')).not.toBeInTheDocument()
expect(document.title).toBe('Sighting not found · UFOund it')
})

it('shows an error and lets the user retry', async () => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
renderDetailPage('/sightings/1', [
{ ...found, error: new Error('Network down') },
found,
])

expect(await screen.findByRole('alert')).toHaveTextContent(
'Check your connection',
)
expect(
screen.getByRole('heading', { name: 'Could not load sighting' }),
).toBeInTheDocument()

fireEvent.click(screen.getByRole('button', { name: 'Try again' }))

expect(
await screen.findByRole('heading', { name: 'San Marcos, TX, US' }),
).toBeInTheDocument()
})
})
2 changes: 1 addition & 1 deletion frontend/src/tests/SightingList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ComponentProps } from 'react'
import { MemoryRouter } from 'react-router'
import { describe, expect, it, vi } from 'vitest'
import SightingList from '../components/SightingList'
import { mockSightings } from '../data/mock-sightings'
import { mockSightings } from './mock-sightings'

vi.mock('../components/SightingMap', () => ({
default: () => <div data-testid="sighting-map" />,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/tests/SightingMap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import SightingMap from '../components/SightingMap'
import { mockSightings } from '../data/mock-sightings'
import { mockSightings } from './mock-sightings'

type MapProps = {
children: ReactNode
Expand Down
2 changes: 0 additions & 2 deletions frontend/src/tests/SubmitSightingPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { GraphQLError } from 'graphql'
import { fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { describe, expect, it, vi } from 'vitest'
import { getSightings } from '../data/sighting-store'
import { CREATE_SIGHTING_MUTATION } from '../graphql/mutations'
import SubmitSightingPage from '../pages/SubmitSightingPage'

Expand Down Expand Up @@ -86,7 +85,6 @@ describe('SubmitSightingPage', () => {
expect(
screen.getByRole('link', { name: 'View your sighting' }),
).toHaveAttribute('href', '/sightings/90001')
expect(getSightings()[0]).toMatchObject({ id: '90001', state: '' })
})

it('shows field errors returned by the server next to the field', async () => {
Expand Down
15 changes: 0 additions & 15 deletions frontend/src/tests/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mockSightings } from '../data/mock-sightings'
import { useDebouncedValue } from '../hooks/use-debounced-value'
import { useSighting } from '../hooks/use-sighting'

describe('sighting hooks', () => {
afterEach(() => {
Expand All @@ -23,17 +21,4 @@ describe('sighting hooks', () => {
act(() => vi.advanceTimersByTime(1))
expect(result.current).toBe('second')
})

it('looks up an existing sighting and returns undefined for an unknown id', () => {
const { result, rerender } = renderHook(({ id }) => useSighting(id), {
initialProps: { id: mockSightings[0].id as string | undefined },
})
expect(result.current).toEqual(mockSightings[0])

rerender({ id: 'not-a-sighting' })
expect(result.current).toBeUndefined()

rerender({ id: undefined })
expect(result.current).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import type { UfoSighting } from '../types/ufo-sighting'

/**
* Hand-written sample sightings. The search page now reads from GraphQL,
* but the detail page still looks sightings up here, and tests use them as
* fixtures. Remove from the app once the detail page uses GraphQL.
*/
/** Hand-written sample sightings, used as fixtures by the tests. */
export const mockSightings: UfoSighting[] = [
{
id: '1',
Expand Down