Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions frontend/src/pages/SightingDetailPage.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
gap: 24px;
}

.sighting-detail__announcement {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}

.sighting-detail__back {
justify-self: start;
color: var(--accent);
Expand Down
115 changes: 65 additions & 50 deletions frontend/src/pages/SightingDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
formatLocation,
formatShape,
} from '../utils/format-sighting'
import '../components/SightingList.css'
import './SightingDetailPage.css'

/**
Expand Down Expand Up @@ -59,63 +60,77 @@ function SightingDetailPage() {
</Link>
)

if (!sighting) {
return (
<div className="sighting-detail">
return (
<>
<p
className="sighting-detail__announcement"
aria-live="polite"
aria-atomic="true"
>
{error ? '' : loading ? 'Loading sighting…' : title}
</p>
<article className="sighting-detail" aria-busy={loading}>
{backLink}
<h1>{title}</h1>
{error ? (
<div role="alert">
{CombinedGraphQLErrors.is(error) ? (
<p>The server could not load this sighting. Try again later.</p>
{!sighting ? (
<>
<h1>{title}</h1>
{error ? (
<div role="alert">
{CombinedGraphQLErrors.is(error) ? (
<p>
The server could not load this sighting. Try again later.
</p>
) : (
<>
<p>Check your connection and try again.</p>
<button
type="button"
className="sighting-results__button"
onClick={retry}
>
Try again
</button>
</>
)}
</div>
) : (
<>
<p>Check your connection and try again.</p>
<button type="button" onClick={retry}>
Try again
</button>
</>
!loading && <p>There is no sighting with id “{id}”.</p>
)}
</div>
</>
) : (
!loading && <p>There is no sighting with id “{id}”.</p>
)}
</div>
)
}
<>
<header className="sighting-detail__header">
<h1>{title}</h1>
<time dateTime={sighting.datetime ?? undefined}>
{formatDate(sighting.datetime)}
</time>
</header>

return (
<article className="sighting-detail">
{backLink}
<header className="sighting-detail__header">
<h1>{title}</h1>
<time dateTime={sighting.datetime ?? undefined}>
{formatDate(sighting.datetime)}
</time>
</header>
<SightingMap
sighting={sighting}
interactive
zoom={11}
className="sighting-detail__map"
/>

<SightingMap
sighting={sighting}
interactive
zoom={11}
className="sighting-detail__map"
/>
<dl className="sighting-detail__facts">
<div>
<dt>Shape</dt>
<dd>{formatShape(sighting.shape)}</dd>
</div>
<div>
<dt>Duration</dt>
<dd>{formatDuration(sighting.durationSeconds)}</dd>
</div>
</dl>

<dl className="sighting-detail__facts">
<div>
<dt>Shape</dt>
<dd>{formatShape(sighting.shape)}</dd>
</div>
<div>
<dt>Duration</dt>
<dd>{formatDuration(sighting.durationSeconds)}</dd>
</div>
</dl>

{sighting.comments && (
<p className="sighting-detail__comments">{sighting.comments}</p>
)}
</article>
{sighting.comments && (
<p className="sighting-detail__comments">{sighting.comments}</p>
)}
</>
)}
</article>
</>
)
}

Expand Down
41 changes: 37 additions & 4 deletions frontend/src/tests/SightingDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
type MockedProviderProps,
} from '@apollo/client/testing/react'
import { GraphQLError } from 'graphql'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } 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'
Expand Down Expand Up @@ -56,14 +56,28 @@ describe('SightingDetailPage', () => {

it('renders sighting details and preserves the originating search link', async () => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
renderDetailPage('/sightings/1', [found], { fromSearch: '?q=San+Marcos' })
const { container } = renderDetailPage('/sightings/1', [found], {
fromSearch: '?q=San+Marcos',
})
const announcement = container.querySelector('[aria-live="polite"]')
const content = screen.getByRole('article')

expect(announcement).toHaveTextContent('Loading sighting…')
expect(announcement).toHaveAttribute('aria-atomic', 'true')
expect(content).toHaveAttribute('aria-busy', 'true')
expect(content).not.toContainElement(announcement as HTMLElement)

expect(
screen.getByRole('heading', { name: 'Loading sighting…' }),
).toBeInTheDocument()
expect(
await screen.findByRole('heading', { name: 'San Marcos, TX, US' }),
).toBeInTheDocument()
expect(container.querySelectorAll('[aria-live]')).toHaveLength(1)
expect(container.querySelector('[aria-live]')).toBe(announcement)
expect(announcement).toHaveTextContent('San Marcos, TX, US')
expect(screen.getByRole('article')).toBe(content)
expect(content).toHaveAttribute('aria-busy', 'false')
expect(screen.getByText('45 min')).toBeInTheDocument()
expect(screen.getByText(/This event took place/)).toBeInTheDocument()
expect(
Expand Down Expand Up @@ -129,14 +143,19 @@ describe('SightingDetailPage', () => {
).toHaveAttribute('href', '/')
expect(screen.queryByTestId('detail-map')).not.toBeInTheDocument()
expect(document.title).toBe('Sighting not found · UFOund it')
expect(screen.getByRole('article')).toHaveAttribute('aria-busy', 'false')
expect(document.querySelector('[aria-live]')).toHaveTextContent(
'Sighting not found',
)
})

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

expect(await screen.findByRole('alert')).toHaveTextContent(
'Check your connection',
Expand All @@ -145,10 +164,24 @@ describe('SightingDetailPage', () => {
screen.getByRole('heading', { name: 'Could not load sighting' }),
).toBeInTheDocument()

fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(announcement).toBeEmptyDOMElement()
expect(screen.getByRole('article')).toHaveAttribute('aria-busy', 'false')
const retryButton = screen.getByRole('button', { name: 'Try again' })
expect(retryButton).toHaveClass('sighting-results__button')
fireEvent.click(retryButton)

await waitFor(() =>
expect(announcement).toHaveTextContent('Loading sighting…'),
)
expect(screen.getByRole('article')).toHaveAttribute('aria-busy', 'true')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()

expect(
await screen.findByRole('heading', { name: 'San Marcos, TX, US' }),
).toBeInTheDocument()
expect(container.querySelectorAll('[aria-live]')).toHaveLength(1)
expect(container.querySelector('[aria-live]')).toBe(announcement)
expect(announcement).toHaveTextContent('San Marcos, TX, US')
expect(screen.getByRole('article')).toHaveAttribute('aria-busy', 'false')
})
})