Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 4 additions & 0 deletions backend/src/db/sightings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ export async function findSightings(args: SightingsArgs) {
}

export async function findSightingById(id: string) {
// MySQL reads '1abc' as 1 when comparing it with a number column, which
// would show the wrong sighting instead of "not found".
if (!/^\d+$/.test(id)) return null

const [rows] = await pool.query<RowDataPacket[]>(
`${SELECT} ${FROM} WHERE s.UFO_SIGHTING_ID = ?`,
[id],
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.

28 changes: 22 additions & 6 deletions frontend/src/pages/SightingDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
formatDate,
formatDuration,
formatLocation,
formatShape,
} from '../utils/format-sighting'
import './SightingDetailPage.css'

Expand All @@ -27,11 +28,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 +63,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 All @@ -83,15 +97,17 @@ function SightingDetailPage() {
<dl className="sighting-detail__facts">
<div>
<dt>Shape</dt>
<dd>{sighting.shape}</dd>
<dd>{formatShape(sighting.shape)}</dd>
</div>
<div>
<dt>Duration</dt>
<dd>{formatDuration(sighting.durationSeconds)}</dd>
</div>
</dl>

<p className="sighting-detail__comments">{sighting.comments}</p>
{sighting.comments && (
<p className="sighting-detail__comments">{sighting.comments}</p>
)}
</article>
)
}
Expand Down
24 changes: 3 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 All @@ -135,6 +115,9 @@ function SubmitSightingPage() {

try {
const { data } = await createSighting({
// The cached lists don't contain the new sighting. Dropping them
// makes the search page fetch fresh results when it is opened.
update: (cache) => cache.evict({ fieldName: 'sightings' }),
variables: {
input: {
// datetime-local gives local time without timezone, which is
Expand All @@ -152,7 +135,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
92 changes: 79 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,35 @@ describe('SightingDetailPage', () => {
expect(document.title).toBe('San Marcos, TX, US · UFOund it')
})

it('handles missing sightings and links back to the unfiltered list', () => {
it('shows fallbacks for a missing shape and hides missing comments', async () => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
renderDetailPage('/sightings/missing')
const sighting = {
...found.result.data.sighting,
shape: null,
comments: null,
}
renderDetailPage('/sightings/1', [
{ ...found, result: { data: { sighting } } },
])

expect(
screen.getByRole('heading', { name: 'Sighting not found' }),
await screen.findByRole('heading', { name: 'San Marcos, TX, US' }),
).toBeInTheDocument()
expect(screen.getByText('Unknown')).toBeInTheDocument()
expect(screen.queryByText(/This event took place/)).not.toBeInTheDocument()
})

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

expect(
await screen.findByRole('heading', { name: 'Sighting not found' }),
).toBeInTheDocument()
expect(
screen.getByText('There is no sighting with id “missing”.'),
Expand All @@ -70,4 +115,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
Loading