-
- {/* The datalist suggests names as the user types. The browser's
- own autocomplete is off so its history doesn't crowd them. */}
- updateValue('country', event.target.value)}
- aria-invalid={Boolean(errors.country)}
- aria-describedby={
- errors.country ? 'sighting-country-error' : undefined
- }
- required
- />
-
- {errors.country && (
-
- {errors.country}
-
- )}
-
- {/* After the country in the page order, so it appears where the
- keyboard focus goes next once "United States" is chosen. */}
- {isUnitedStates && (
-
+
+ {/* The datalist suggests names as the user types. The browser's
+ own autocomplete is off so its history doesn't crowd them. */}
+
+ updateValue('country', event.target.value)
+ }
+ aria-invalid={Boolean(errors.country)}
+ aria-describedby={
+ errors.country ? 'sighting-country-error' : undefined
+ }
+ required
+ />
+
+ {errors.country && (
+
+ {errors.country}
+
+ )}
+
+ {/* After the country in the page order, so it appears where the
+ keyboard focus goes next once "United States" is chosen. */}
+ {isUnitedStates && (
+
+ )}
+ {/* Always rendered, so screen readers announce changes to it. */}
- {errors.longitude}
+ {lookup.status === 'loading' && 'Looking up the place…'}
+ {lookup.status === 'failed' &&
+ "Couldn't find the place. Fill in the fields manually."}
- )}
-
-
-
-
-
Where did it happen?
-
- Click the map to place the marker and fill in the city and
- country, or enter coordinates above.
-
+
-
- {/* Always rendered, so screen readers announce changes to it. */}
-
- {lookup.status === 'loading' && 'Looking up the place…'}
- {lookup.status === 'failed' &&
- "Couldn't find the place. Fill in the fields manually."}
-
-
- Place names from{' '}
- OpenStreetMap{' '}
- via Nominatim.
-
diff --git a/frontend/src/tests/SubmitSightingPage.test.tsx b/frontend/src/tests/SubmitSightingPage.test.tsx
index 72a39d4..58e166b 100644
--- a/frontend/src/tests/SubmitSightingPage.test.tsx
+++ b/frontend/src/tests/SubmitSightingPage.test.tsx
@@ -110,7 +110,8 @@ function fillFields() {
const fields: [string, string][] = [
['Date and time *', input.datetime],
['Shape *', input.shape],
- ['Duration in seconds *', String(input.durationSeconds)],
+ // The unit defaults to minutes: 2 minutes are 120 seconds.
+ ['Duration *', '2'],
['City *', input.city],
// The user types the name. The mocks expect `input.country`, "no".
['Country *', 'Norway'],
@@ -343,6 +344,30 @@ describe('SubmitSightingPage', () => {
).toBeInTheDocument()
})
+ it('converts the chosen unit to seconds', async () => {
+ renderPage([
+ {
+ request: {
+ query: CREATE_SIGHTING_MUTATION,
+ variables: { input: { ...input, durationSeconds: 5400 } },
+ },
+ result: {
+ data: { createSighting: { id: '9', __typename: 'Sighting' } },
+ },
+ },
+ ])
+ fillFields()
+ fireEvent.change(screen.getByLabelText('Duration *'), {
+ target: { value: '1.5' },
+ })
+ fireEvent.change(screen.getByLabelText('Unit'), {
+ target: { value: 'hours' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Submit sighting' }))
+
+ await screen.findByRole('heading', { name: 'Sighting submitted' })
+ })
+
it('rejects years before 1900 when leaving the field', async () => {
renderPage([])
const field = screen.getByLabelText('Date and time *')
From 2af82fa5befd2027887a2b039bf139892c876b32 Mon Sep 17 00:00:00 2001
From: Asgeir Jacobsen
Date: Thu, 5 Nov 2026 11:12:24 +0100
Subject: [PATCH 4/7] Refactor: Split report form into smaller parts
Move validation and duration conversion to utils/sighting-form.ts,
form state and submit handling to a useSightingForm hook, and the
shared field markup and location section to their own components.
The page now only lays out the form.
Behavior is unchanged, except that a server error on the State field
now shows under it.
AI usage: Claude Code was used to refactor the code.
Closes #167
---
frontend/src/components/FormField.tsx | 39 ++
frontend/src/components/LocationSection.tsx | 195 ++++++
frontend/src/hooks/use-sighting-form.ts | 188 ++++++
frontend/src/pages/SubmitSightingPage.tsx | 679 +++-----------------
frontend/src/utils/field-props.ts | 14 +
frontend/src/utils/sighting-form.ts | 153 +++++
6 files changed, 664 insertions(+), 604 deletions(-)
create mode 100644 frontend/src/components/FormField.tsx
create mode 100644 frontend/src/components/LocationSection.tsx
create mode 100644 frontend/src/hooks/use-sighting-form.ts
create mode 100644 frontend/src/utils/field-props.ts
create mode 100644 frontend/src/utils/sighting-form.ts
diff --git a/frontend/src/components/FormField.tsx b/frontend/src/components/FormField.tsx
new file mode 100644
index 0000000..79a3bed
--- /dev/null
+++ b/frontend/src/components/FormField.tsx
@@ -0,0 +1,39 @@
+import type { ReactNode } from 'react'
+
+interface FormFieldProps {
+ /** The id of the control inside. The error gets `${id}-error`. */
+ id: string
+ label: string
+ required?: boolean
+ error?: string
+ children: ReactNode
+}
+
+/** A label, a control and the error message under it. */
+export function FormField({
+ id,
+ label,
+ required,
+ error,
+ children,
+}: FormFieldProps) {
+ return (
+
+
+ {children}
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/LocationSection.tsx b/frontend/src/components/LocationSection.tsx
new file mode 100644
index 0000000..9d97750
--- /dev/null
+++ b/frontend/src/components/LocationSection.tsx
@@ -0,0 +1,195 @@
+import type { LocationMode } from '../hooks/use-sighting-form'
+import type { LookupStatus } from '../hooks/use-reverse-geocode'
+import { countries } from '../utils/countries'
+import type { FormErrors, FormValues } from '../utils/sighting-form'
+import { fieldProps } from '../utils/field-props'
+import { FormField } from './FormField'
+import LocationPicker from './LocationPicker'
+
+interface LocationSectionProps {
+ values: FormValues
+ errors: FormErrors
+ mode: LocationMode
+ onModeChange: (mode: LocationMode) => void
+ onChange: (field: keyof FormValues, value: string) => void
+ onPositionChange: (position: [number, number]) => void
+ lookupStatus: LookupStatus
+ isUnitedStates: boolean
+}
+
+/** The place of the sighting: typed fields on one side, a map on the other. */
+function LocationSection({
+ values,
+ errors,
+ mode,
+ onModeChange,
+ onChange,
+ onPositionChange,
+ lookupStatus,
+ isUnitedStates,
+}: LocationSectionProps) {
+ return (
+
+
+
Where did it happen?
+
+ Fill in the place, or click the map to fill it in for you.
+
+
+
+
+
+
+
+
+
+
+
+ onChange('city', event.target.value)}
+ required
+ />
+
+
+ {/* The datalist suggests names as the user types. The browser's
+ own autocomplete is off so its history doesn't crowd them. */}
+ onChange('country', event.target.value)}
+ required
+ />
+
+
+ {/* After the country in the page order, so it appears where the
+ keyboard focus goes next once "United States" is chosen. */}
+ {isUnitedStates && (
+
+ onChange('state', event.target.value)}
+ />
+
+ )}
+
+ )}
+ {/* Always rendered, so screen readers announce changes to it. */}
+
+ {lookupStatus === 'loading' && 'Looking up the place…'}
+ {lookupStatus === 'failed' &&
+ "Couldn't find the place. Fill in the fields manually."}
+
+
+
+
+ )
+}
+
+export default LocationSection
diff --git a/frontend/src/hooks/use-sighting-form.ts b/frontend/src/hooks/use-sighting-form.ts
new file mode 100644
index 0000000..50935ea
--- /dev/null
+++ b/frontend/src/hooks/use-sighting-form.ts
@@ -0,0 +1,188 @@
+import { useMutation } from '@apollo/client/react'
+import { useEffect, useRef, useState, type FormEvent } from 'react'
+import { CREATE_SIGHTING_MUTATION } from '../graphql/mutations'
+import { countryCodeFromName, countryNameFromCode } from '../utils/countries'
+import type { Place } from '../utils/reverse-geocode'
+import {
+ datetimeError,
+ durationInSeconds,
+ hasLocationError,
+ initialValues,
+ placeFields,
+ serverFieldErrors,
+ validate,
+ type FormErrors,
+ type FormValues,
+ type PlaceField,
+} from '../utils/sighting-form'
+import { useReverseGeocode } from './use-reverse-geocode'
+
+export type LocationMode = 'map' | 'manual'
+
+/**
+ * State and behavior of the report form: the values, validation, the
+ * submit and what happens to focus afterwards. The page only renders it.
+ */
+export function useSightingForm() {
+ const [values, setValues] = useState(initialValues)
+ const [errors, setErrors] = useState({})
+ const [submittedId, setSubmittedId] = useState()
+ const [saveFailed, setSaveFailed] = useState(false)
+ const [locationMode, setLocationMode] = useState('map')
+ const [createSighting, { loading }] = useMutation(CREATE_SIGHTING_MUTATION)
+ // Fixing a field sets its error to undefined but keeps the key, so check
+ // the values rather than counting keys.
+ const hasErrors = Object.values(errors).some(Boolean)
+ // Only US sightings have a state. The imported data uses US state codes,
+ // and other countries' regions don't fit that field.
+ const isUnitedStates = countryCodeFromName(values.country) === 'us'
+
+ const formRef = useRef(null)
+ const headingRef = useRef(null)
+ // Set by the handlers below, so focus only moves in response to the user,
+ // never on the first render.
+ const focusFirstError = useRef(false)
+ const focusHeading = useRef(false)
+
+ // After a failed submit, screen readers hear the first invalid field
+ // together with its error, and keyboard users start fixing it right away.
+ // Runs after render so the field already has aria-describedby.
+ useEffect(() => {
+ if (!focusFirstError.current) return
+ focusFirstError.current = false
+ formRef.current
+ ?.querySelector('[aria-invalid="true"]:not(:disabled)')
+ ?.focus()
+ }, [errors])
+
+ // Switching between the form and the confirmation removes the focused
+ // button, so focus goes to the new heading instead of the page body.
+ useEffect(() => {
+ if (!focusHeading.current) return
+ focusHeading.current = false
+ headingRef.current?.focus()
+ }, [submittedId])
+
+ function updateValue(field: keyof FormValues, value: string) {
+ setValues((previous) => ({ ...previous, [field]: value }))
+ setErrors((previous) => ({ ...previous, [field]: undefined }))
+ }
+
+ // Tell the user as soon as they leave the date field, not only on submit.
+ // An empty value is left alone, since the browser reports one while the
+ // date is half typed. Typing clears the message again (see updateValue).
+ function checkDatetime(datetime: string) {
+ const message = datetime ? datetimeError(datetime) : undefined
+ if (message) setErrors((previous) => ({ ...previous, datetime: message }))
+ }
+
+ // The pin decides the place. Whatever the fields held before, typed or
+ // from an earlier pin, belongs to the old location, so every field is
+ // replaced, and emptied where the lookup found nothing (e.g. at sea).
+ // The user can still edit the fields after the pin.
+ function fillPlace(place: Place | null) {
+ const found: Record = {
+ city: place?.city ?? '',
+ state: place?.state ?? '',
+ country: (place?.country && countryNameFromCode(place.country)) ?? '',
+ }
+ for (const field of placeFields) {
+ if (values[field] !== found[field]) updateValue(field, found[field])
+ }
+ }
+
+ const lookup = useReverseGeocode(fillPlace)
+
+ // Only map clicks look up the place. Typed coordinates would send a
+ // request for every keystroke.
+ function updatePosition(position: [number, number]) {
+ const [latitude, longitude] = position
+ updateValue('latitude', latitude.toFixed(6))
+ updateValue('longitude', longitude.toFixed(6))
+ void lookup.lookUp(position)
+ }
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault()
+ if (loading) return
+ setSaveFailed(false)
+ const nextErrors = validate(values)
+ setErrors(nextErrors)
+ // The form shows country names, but the database stores codes.
+ // validate() has already reported a name that has no code.
+ const country = countryCodeFromName(values.country)
+ if (Object.keys(nextErrors).length > 0 || country === null) {
+ if (hasLocationError(nextErrors) || country === null) {
+ setLocationMode('manual')
+ }
+ focusFirstError.current = true
+ return
+ }
+
+ 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
+ // the format the API stores.
+ datetime: values.datetime,
+ city: values.city.trim(),
+ // A state typed before switching to another country is hidden,
+ // so it isn't sent either.
+ state: isUnitedStates ? values.state.trim() || null : null,
+ country,
+ shape: values.shape,
+ durationSeconds: durationInSeconds(values),
+ comments: values.comments.trim(),
+ latitude: Number(values.latitude),
+ longitude: Number(values.longitude),
+ },
+ },
+ })
+ if (!data) throw new Error('createSighting returned no data')
+ focusHeading.current = true
+ setSubmittedId(data.createSighting.id)
+ } catch (error) {
+ const fieldErrors = serverFieldErrors(error)
+ if (fieldErrors) {
+ if (hasLocationError(fieldErrors)) setLocationMode('manual')
+ focusFirstError.current = true
+ setErrors(fieldErrors)
+ } else {
+ setSaveFailed(true)
+ }
+ }
+ }
+
+ function startOver() {
+ setValues(initialValues)
+ lookup.reset()
+ setLocationMode('map')
+ focusHeading.current = true
+ setSubmittedId(undefined)
+ }
+
+ return {
+ values,
+ setValues,
+ errors,
+ hasErrors,
+ submittedId,
+ saveFailed,
+ saving: loading,
+ locationMode,
+ setLocationMode,
+ isUnitedStates,
+ lookupStatus: lookup.status,
+ formRef,
+ headingRef,
+ updateValue,
+ checkDatetime,
+ updatePosition,
+ handleSubmit,
+ startOver,
+ }
+}
diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx
index 4992425..1c20fbb 100644
--- a/frontend/src/pages/SubmitSightingPage.tsx
+++ b/frontend/src/pages/SubmitSightingPage.tsx
@@ -1,24 +1,15 @@
-import { CombinedGraphQLErrors } from '@apollo/client'
-import { useMutation, useQuery } from '@apollo/client/react'
-import { useEffect, useRef, useState, type FormEvent } from 'react'
+import { useQuery } from '@apollo/client/react'
import { Link } from 'react-router'
-import {
- CREATE_SIGHTING_MUTATION,
- type SightingFieldError,
-} from '../graphql/mutations'
-import LocationPicker from '../components/LocationPicker'
+import { FormField } from '../components/FormField'
+import { fieldProps } from '../utils/field-props'
+import LocationSection from '../components/LocationSection'
import { SHAPES_QUERY } from '../graphql/queries'
-
-import { useReverseGeocode } from '../hooks/use-reverse-geocode'
-import {
- countries,
- countryCodeFromName,
- countryNameFromCode,
-} from '../utils/countries'
-import type { Place } from '../utils/reverse-geocode'
import { useDocumentTitle } from '../hooks/use-document-title'
+import { useSightingForm } from '../hooks/use-sighting-form'
+import { durationUnits, type DurationUnit } from '../utils/sighting-form'
import './SubmitSightingPage.css'
+// Shown when the shapes can't be loaded, so the form still works.
const fallbackShapes = [
'circle',
'cylinder',
@@ -32,152 +23,9 @@ const fallbackShapes = [
'other',
]
-/** The API stores seconds, so the form converts what the user enters. */
-const durationUnits = {
- seconds: 1,
- minutes: 60,
- hours: 3600,
-} as const
-type DurationUnit = keyof typeof durationUnits
-// Matches MAX_DURATION_SECONDS in the backend's validate-sighting.ts.
-const MAX_DURATION_SECONDS = 365 * 24 * 60 * 60
-
-function durationInSeconds(values: FormValues): number {
- return Math.round(
- Number(values.duration) * durationUnits[values.durationUnit],
- )
-}
-
-interface FormValues {
- datetime: string
- city: string
- state: string
- country: string
- shape: string
- duration: string
- durationUnit: DurationUnit
- comments: string
- latitude: string
- longitude: string
-}
-
-type FormErrors = Partial>
-
-/** The fields a map pin can fill in. */
-const placeFields = ['city', 'state', 'country'] as const
-type PlaceField = (typeof placeFields)[number]
-
-const initialValues: FormValues = {
- datetime: '',
- city: '',
- state: '',
- country: '',
- shape: '',
- duration: '',
- durationUnit: 'minutes',
- comments: '',
- latitude: '',
- longitude: '',
-}
-
-/**
- * The current local time in the format a datetime-local input uses
- * (YYYY-MM-DDTHH:mm). The input holds local time, so toISOString() (UTC)
- * would be off by the timezone offset.
- */
-function nowForInput(): string {
- const now = new Date()
- const pad = (n: number) => String(n).padStart(2, '0')
- return (
- `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
- `T${pad(now.getHours())}:${pad(now.getMinutes())}`
- )
-}
-
-// Matches EARLIEST_YEAR in the backend's validate-sighting.ts.
-const EARLIEST_YEAR = 1900
-
-/** Rules the browser can check itself. The server checks them again. */
-function datetimeError(datetime: string): string | undefined {
- if (!datetime) return 'Enter when you saw the object.'
- // Same-format strings compare correctly as text.
- if (datetime > nowForInput()) {
- return 'The date and time cannot be in the future.'
- }
- if (Number(datetime.slice(0, 4)) < EARLIEST_YEAR) {
- return `The year must be ${EARLIEST_YEAR} or later.`
- }
-}
-
-function validate(values: FormValues): FormErrors {
- const errors: FormErrors = {}
- const seconds = durationInSeconds(values)
- const latitude = Number(values.latitude)
- const longitude = Number(values.longitude)
-
- const datetimeMessage = datetimeError(values.datetime)
- if (datetimeMessage) errors.datetime = datetimeMessage
- if (!values.city.trim()) errors.city = 'Enter the city.'
- if (!values.country.trim()) {
- errors.country = 'Enter the country.'
- } else if (!countryCodeFromName(values.country)) {
- errors.country = 'Choose a country from the suggestions.'
- }
- if (!values.shape) errors.shape = 'Choose the closest shape.'
- if (!values.duration || !Number.isFinite(seconds) || seconds < 1) {
- errors.duration = 'Enter how long it lasted, at least 1 second.'
- } else if (seconds > MAX_DURATION_SECONDS) {
- errors.duration = 'The duration can be at most one year.'
- }
- if (!values.comments.trim()) errors.comments = 'Describe what you saw.'
- if (
- !values.latitude ||
- !Number.isFinite(latitude) ||
- latitude < -90 ||
- latitude > 90
- ) {
- errors.latitude = 'Enter a latitude between -90 and 90.'
- }
- if (
- !values.longitude ||
- !Number.isFinite(longitude) ||
- longitude < -180 ||
- longitude > 180
- ) {
- errors.longitude = 'Enter a longitude between -180 and 180.'
- }
- return errors
-}
-
-/**
- * Turns the server's BAD_USER_INPUT `fieldErrors` into form errors, so the
- * rules only the server checks (dates in the future, text length) show up
- * next to the right field. Returns null for any other kind of error.
- */
-function serverFieldErrors(error: unknown): FormErrors | null {
- if (!CombinedGraphQLErrors.is(error)) return null
- const invalid = error.errors.find(
- (graphQLError) => graphQLError.extensions?.code === 'BAD_USER_INPUT',
- )
- const fieldErrors = invalid?.extensions?.fieldErrors as
- SightingFieldError[] | undefined
- if (!fieldErrors) return null
-
- const errors: FormErrors = {}
- for (const { field, message } of fieldErrors) {
- // The API counts the duration in seconds, the form shows it as entered.
- errors[field === 'durationSeconds' ? 'duration' : field] = message
- }
- return errors
-}
-
function SubmitSightingPage() {
- const [values, setValues] = useState(initialValues)
- const [errors, setErrors] = useState({})
- const [submittedId, setSubmittedId] = useState()
- const [saveFailed, setSaveFailed] = useState(false)
- const [locationMode, setLocationMode] = useState<'map' | 'manual'>('map')
- const [createSighting, { loading }] = useMutation(CREATE_SIGHTING_MUTATION)
+ const { formRef, headingRef, ...form } = useSightingForm()
+ const { values, errors } = form
const {
data: shapeData,
loading: shapesLoading,
@@ -186,142 +34,12 @@ function SubmitSightingPage() {
const shapes = shapeData?.shapes.length ? shapeData.shapes : fallbackShapes
const shapesUnavailable =
Boolean(shapesError) || shapeData?.shapes.length === 0
- // Fixing a field sets its error to undefined but keeps the key, so check
- // the values rather than counting keys.
- const hasErrors = Object.values(errors).some(Boolean)
- // Only US sightings have a state. The imported data uses US state codes,
- // and other countries' regions don't fit that field.
- const isUnitedStates = countryCodeFromName(values.country) === 'us'
-
- useDocumentTitle(submittedId ? 'Sighting submitted' : 'Report a sighting')
-
- const formRef = useRef(null)
- const headingRef = useRef(null)
- // Set by the handlers below, so focus only moves in response to the user,
- // never on the first render.
- const focusFirstError = useRef(false)
- const focusHeading = useRef(false)
-
- // After a failed submit, screen readers hear the first invalid field
- // together with its error, and keyboard users start fixing it right away.
- // Runs after render so the field already has aria-describedby.
- useEffect(() => {
- if (!focusFirstError.current) return
- focusFirstError.current = false
- formRef.current
- ?.querySelector('[aria-invalid="true"]:not(:disabled)')
- ?.focus()
- }, [errors])
-
- // Switching between the form and the confirmation removes the focused
- // button, so focus goes to the new heading instead of the page body.
- useEffect(() => {
- if (!focusHeading.current) return
- focusHeading.current = false
- headingRef.current?.focus()
- }, [submittedId])
-
- function updateValue(field: keyof FormValues, value: string) {
- setValues((previous) => ({ ...previous, [field]: value }))
- setErrors((previous) => ({ ...previous, [field]: undefined }))
- }
-
- // The pin decides the place. Whatever the fields held before, typed or
- // from an earlier pin, belongs to the old location, so every field is
- // replaced, and emptied where the lookup found nothing (e.g. at sea).
- // The user can still edit the fields after the pin.
- function fillPlace(place: Place | null) {
- const found: Record = {
- city: place?.city ?? '',
- state: place?.state ?? '',
- country: (place?.country && countryNameFromCode(place.country)) ?? '',
- }
- for (const field of placeFields) {
- if (values[field] !== found[field]) updateValue(field, found[field])
- }
- }
- const lookup = useReverseGeocode(fillPlace)
-
- // Only map clicks look up the place. Typed coordinates would send a
- // request for every keystroke.
- function updatePosition(position: [number, number]) {
- const [latitude, longitude] = position
- updateValue('latitude', latitude.toFixed(6))
- updateValue('longitude', longitude.toFixed(6))
- void lookup.lookUp(position)
- }
-
- async function handleSubmit(event: FormEvent) {
- event.preventDefault()
- if (loading) return
- setSaveFailed(false)
- const nextErrors = validate(values)
- setErrors(nextErrors)
- // The form shows country names, but the database stores codes.
- // validate() has already reported a name that has no code.
- const country = countryCodeFromName(values.country)
- if (Object.keys(nextErrors).length > 0 || country === null) {
- if (
- nextErrors.city ||
- nextErrors.country ||
- nextErrors.latitude ||
- nextErrors.longitude ||
- country === null
- ) {
- setLocationMode('manual')
- }
- focusFirstError.current = true
- return
- }
-
- 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
- // the format the API stores.
- datetime: values.datetime,
- city: values.city.trim(),
- // A state typed before switching to another country is hidden,
- // so it isn't sent either.
- state: isUnitedStates ? values.state.trim() || null : null,
- country,
- shape: values.shape,
- durationSeconds: durationInSeconds(values),
- comments: values.comments.trim(),
- latitude: Number(values.latitude),
- longitude: Number(values.longitude),
- },
- },
- })
- if (!data) throw new Error('createSighting returned no data')
- focusHeading.current = true
- setSubmittedId(data.createSighting.id)
- } catch (error) {
- const fieldErrors = serverFieldErrors(error)
- if (fieldErrors) {
- if (
- fieldErrors.city ||
- fieldErrors.country ||
- fieldErrors.state ||
- fieldErrors.latitude ||
- fieldErrors.longitude
- ) {
- setLocationMode('manual')
- }
- focusFirstError.current = true
- setErrors(fieldErrors)
- } else {
- setSaveFailed(true)
- }
- }
- }
+ useDocumentTitle(
+ form.submittedId ? 'Sighting submitted' : 'Report a sighting',
+ )
- if (submittedId) {
+ if (form.submittedId) {
return (
@@ -334,20 +52,11 @@ function SubmitSightingPage() {
View your sighting
-
@@ -369,59 +78,45 @@ function SubmitSightingPage() {
-
-
- {hasErrors && (
+
+ {form.hasErrors && (
Please correct the highlighted fields.
)}
- {saveFailed && (
+ {form.saveFailed && (
Your sighting could not be saved. Check your connection and try
again.
@@ -741,9 +212,9 @@ function SubmitSightingPage() {
- {loading ? 'Submitting…' : 'Submit sighting'}
+ {form.saving ? 'Submitting…' : 'Submit sighting'}
diff --git a/frontend/src/utils/field-props.ts b/frontend/src/utils/field-props.ts
new file mode 100644
index 0000000..1b7db91
--- /dev/null
+++ b/frontend/src/utils/field-props.ts
@@ -0,0 +1,14 @@
+/**
+ * The props that tie a control to its FormField: the id, whether it is
+ * invalid, and the error message as its description, which is what puts the
+ * message "next to" the field for screen readers. `describedBy` adds other
+ * elements to the description.
+ */
+export function fieldProps(id: string, error?: string, describedBy?: string) {
+ const ids = [error && `${id}-error`, describedBy].filter(Boolean).join(' ')
+ return {
+ id,
+ 'aria-invalid': Boolean(error),
+ 'aria-describedby': ids || undefined,
+ }
+}
diff --git a/frontend/src/utils/sighting-form.ts b/frontend/src/utils/sighting-form.ts
new file mode 100644
index 0000000..3cc016d
--- /dev/null
+++ b/frontend/src/utils/sighting-form.ts
@@ -0,0 +1,153 @@
+import { CombinedGraphQLErrors } from '@apollo/client'
+import type { SightingFieldError } from '../graphql/mutations'
+import { countryCodeFromName } from './countries'
+
+/** The API stores seconds, so the form converts what the user enters. */
+export const durationUnits = {
+ seconds: 1,
+ minutes: 60,
+ hours: 3600,
+} as const
+export type DurationUnit = keyof typeof durationUnits
+
+// These two match the limits in the backend's validate-sighting.ts. The
+// server checks them again, the form only avoids a failed request.
+const MAX_DURATION_SECONDS = 365 * 24 * 60 * 60
+const EARLIEST_YEAR = 1900
+
+export interface FormValues {
+ datetime: string
+ city: string
+ state: string
+ country: string
+ shape: string
+ duration: string
+ durationUnit: DurationUnit
+ comments: string
+ latitude: string
+ longitude: string
+}
+
+export type FormErrors = Partial>
+
+/** The fields a map pin can fill in. */
+export const placeFields = ['city', 'state', 'country'] as const
+export type PlaceField = (typeof placeFields)[number]
+
+export const initialValues: FormValues = {
+ datetime: '',
+ city: '',
+ state: '',
+ country: '',
+ shape: '',
+ duration: '',
+ durationUnit: 'minutes',
+ comments: '',
+ latitude: '',
+ longitude: '',
+}
+
+export function durationInSeconds(values: FormValues): number {
+ return Math.round(
+ Number(values.duration) * durationUnits[values.durationUnit],
+ )
+}
+
+/**
+ * The current local time in the format a datetime-local input uses
+ * (YYYY-MM-DDTHH:mm). The input holds local time, so toISOString() (UTC)
+ * would be off by the timezone offset.
+ */
+function nowForInput(): string {
+ const now = new Date()
+ const pad = (n: number) => String(n).padStart(2, '0')
+ return (
+ `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
+ `T${pad(now.getHours())}:${pad(now.getMinutes())}`
+ )
+}
+
+/** Rules the browser can check itself. The server checks them again. */
+export function datetimeError(datetime: string): string | undefined {
+ if (!datetime) return 'Enter when you saw the object.'
+ // Same-format strings compare correctly as text.
+ if (datetime > nowForInput()) {
+ return 'The date and time cannot be in the future.'
+ }
+ if (Number(datetime.slice(0, 4)) < EARLIEST_YEAR) {
+ return `The year must be ${EARLIEST_YEAR} or later.`
+ }
+}
+
+export function validate(values: FormValues): FormErrors {
+ const errors: FormErrors = {}
+ const seconds = durationInSeconds(values)
+ const latitude = Number(values.latitude)
+ const longitude = Number(values.longitude)
+
+ const datetimeMessage = datetimeError(values.datetime)
+ if (datetimeMessage) errors.datetime = datetimeMessage
+ if (!values.city.trim()) errors.city = 'Enter the city.'
+ if (!values.country.trim()) {
+ errors.country = 'Enter the country.'
+ } else if (!countryCodeFromName(values.country)) {
+ errors.country = 'Choose a country from the suggestions.'
+ }
+ if (!values.shape) errors.shape = 'Choose the closest shape.'
+ if (!values.duration || !Number.isFinite(seconds) || seconds < 1) {
+ errors.duration = 'Enter how long it lasted, at least 1 second.'
+ } else if (seconds > MAX_DURATION_SECONDS) {
+ errors.duration = 'The duration can be at most one year.'
+ }
+ if (!values.comments.trim()) errors.comments = 'Describe what you saw.'
+ if (
+ !values.latitude ||
+ !Number.isFinite(latitude) ||
+ latitude < -90 ||
+ latitude > 90
+ ) {
+ errors.latitude = 'Enter a latitude between -90 and 90.'
+ }
+ if (
+ !values.longitude ||
+ !Number.isFinite(longitude) ||
+ longitude < -180 ||
+ longitude > 180
+ ) {
+ errors.longitude = 'Enter a longitude between -180 and 180.'
+ }
+ return errors
+}
+
+/**
+ * Turns the server's BAD_USER_INPUT `fieldErrors` into form errors, so the
+ * rules only the server checks (text length) show up next to the right
+ * field. Returns null for any other kind of error.
+ */
+export function serverFieldErrors(error: unknown): FormErrors | null {
+ if (!CombinedGraphQLErrors.is(error)) return null
+ const invalid = error.errors.find(
+ (graphQLError) => graphQLError.extensions?.code === 'BAD_USER_INPUT',
+ )
+ const fieldErrors = invalid?.extensions?.fieldErrors as
+ SightingFieldError[] | undefined
+ if (!fieldErrors) return null
+
+ const errors: FormErrors = {}
+ for (const { field, message } of fieldErrors) {
+ // The API counts the duration in seconds, the form shows it as entered.
+ errors[field === 'durationSeconds' ? 'duration' : field] = message
+ }
+ return errors
+}
+
+/** Errors on these fields are only visible in the "Type it in" view. */
+export function hasLocationError(errors: FormErrors): boolean {
+ return Boolean(
+ errors.city ||
+ errors.country ||
+ errors.state ||
+ errors.latitude ||
+ errors.longitude,
+ )
+}
From d58353eb8d5fbc723cf6a83a8a405ffdc6e756c4 Mon Sep 17 00:00:00 2001
From: Asgeir Jacobsen
Date: Thu, 5 Nov 2026 11:27:56 +0100
Subject: [PATCH 5/7] Feat: Add place search to the report form
Let users find a location by name with a keyboard-accessible combobox.
Choosing a result sets the marker and fills in the location fields.
Search only runs on Enter or the button, and shares one request
spacing and cache with the pin lookups, to follow Nominatim's policy.
AI usage: Claude Code used to modify test
Closes #175
---
docs/sustainability.md | 5 +-
frontend/e2e/mock-geocoding.ts | 24 +++
frontend/e2e/submit-sighting.spec.ts | 61 +++++++-
frontend/src/components/LocationPicker.tsx | 31 +++-
frontend/src/components/LocationSection.tsx | 11 +-
frontend/src/components/PlaceSearch.tsx | 131 +++++++++++++++++
frontend/src/hooks/use-place-search.ts | 68 +++++++++
frontend/src/hooks/use-sighting-form.ts | 19 +++
frontend/src/pages/SubmitSightingPage.css | 37 +++++
frontend/src/pages/SubmitSightingPage.tsx | 2 +
.../src/tests/SubmitSightingPage.test.tsx | 138 +++++++++++++++++-
frontend/src/tests/place-search.test.ts | 103 +++++++++++++
frontend/src/utils/nominatim.ts | 50 +++++++
frontend/src/utils/place-search.ts | 64 ++++++++
frontend/src/utils/reverse-geocode.ts | 36 +----
15 files changed, 737 insertions(+), 43 deletions(-)
create mode 100644 frontend/src/components/PlaceSearch.tsx
create mode 100644 frontend/src/hooks/use-place-search.ts
create mode 100644 frontend/src/tests/place-search.test.ts
create mode 100644 frontend/src/utils/nominatim.ts
create mode 100644 frontend/src/utils/place-search.ts
diff --git a/docs/sustainability.md b/docs/sustainability.md
index 51cb930..401597d 100644
--- a/docs/sustainability.md
+++ b/docs/sustainability.md
@@ -49,7 +49,10 @@
repeat a query. So positions are rounded to 3 decimals (about 100 m) and
cached for the visit, and pinning the same spot again sends nothing. A new
pin cancels an unfinished lookup, requests are at least 1 s apart, and typed
- coordinates send nothing.
+ coordinates send nothing. The place search on the same page follows the
+ same policy: it searches only when the user presses Enter or the button,
+ never while typing, shares the one request per second with the pin lookups
+ and caches answers for the visit, ignoring case and extra spaces.
- **CI does each thing once.** Every CI run uses CPU and network on the VM,
so it skips work that was already done:
- A pull request run is cancelled when a newer commit replaces it.
diff --git a/frontend/e2e/mock-geocoding.ts b/frontend/e2e/mock-geocoding.ts
index 64711d9..e225788 100644
--- a/frontend/e2e/mock-geocoding.ts
+++ b/frontend/e2e/mock-geocoding.ts
@@ -20,3 +20,27 @@ export async function mockGeocoding(page: Page, address: NominatimAddress) {
return lookups
}
+
+/** A match of the place search, as Nominatim answers it. */
+export interface MockSearchResult {
+ place_id: number
+ display_name: string
+ lat: string
+ lon: string
+ address: NominatimAddress
+}
+
+/**
+ * Answers the submit form's place search with `results` for any query.
+ * Returns the queries that were sent.
+ */
+export async function mockPlaceSearch(page: Page, results: MockSearchResult[]) {
+ const queries: string[] = []
+
+ await page.route('**/nominatim.openstreetmap.org/search?**', (route) => {
+ queries.push(new URL(route.request().url()).searchParams.get('q') ?? '')
+ return route.fulfill({ json: results })
+ })
+
+ return queries
+}
diff --git a/frontend/e2e/submit-sighting.spec.ts b/frontend/e2e/submit-sighting.spec.ts
index bbd0689..1fa29a5 100644
--- a/frontend/e2e/submit-sighting.spec.ts
+++ b/frontend/e2e/submit-sighting.spec.ts
@@ -1,6 +1,6 @@
import { expect, test, type Locator, type Page } from '@playwright/test'
import type { CreateSightingInput } from '../src/graphql/mutations'
-import { mockGeocoding } from './mock-geocoding'
+import { mockGeocoding, mockPlaceSearch } from './mock-geocoding'
import { answerOnce, mockGraphql } from './mock-graphql'
// Fields by their labels, as a keyboard or screen reader user finds them.
@@ -167,6 +167,65 @@ test('pinning a location fills in the city, state and country', async ({
)
})
+test('a place can be found and set with the keyboard alone', async ({
+ page,
+}) => {
+ const queries = await mockPlaceSearch(page, [
+ {
+ place_id: 1,
+ display_name: 'Roswell, Chaves County, New Mexico, United States',
+ lat: '33.3943',
+ lon: '-104.5230',
+ address: {
+ town: 'Roswell',
+ 'ISO3166-2-lvl4': 'US-NM',
+ country_code: 'us',
+ },
+ },
+ {
+ place_id: 2,
+ display_name: 'Roswell, Fulton County, Georgia, United States',
+ lat: '34.0232',
+ lon: '-84.3616',
+ address: {
+ city: 'Roswell',
+ 'ISO3166-2-lvl4': 'US-GA',
+ country_code: 'us',
+ },
+ },
+ ])
+ const fields = form(page)
+ const search = page.getByRole('combobox', { name: 'Search for a place' })
+ // The shape select has options too, so only look inside the results.
+ const results = page.getByRole('listbox').getByRole('option')
+
+ await tabTo(page, search)
+ await page.keyboard.type('Roswell')
+ await page.keyboard.press('Enter')
+
+ await expect(results).toHaveCount(2)
+ await expect(
+ page.getByRole('status').filter({ hasText: '2 places found' }),
+ ).toBeVisible()
+ expect(queries).toEqual(['roswell'])
+
+ await page.keyboard.press('ArrowDown')
+ await page.keyboard.press('Enter')
+
+ await expect(fields.city).toHaveValue('Roswell')
+ await expect(fields.country).toHaveValue('United States')
+ await expect(fields.state).toHaveValue('NM')
+ await expect(fields.latitude).toHaveValue('33.394300')
+ await expect(fields.longitude).toHaveValue('-104.523000')
+ await expect(page.getByRole('listbox')).toHaveCount(0)
+ await expect(
+ page.getByRole('status').filter({ hasText: 'Location set' }),
+ ).toBeVisible()
+ // The marker is on the map, and Enter did not submit the form.
+ await expect(page.getByAltText('Selected location')).toBeVisible()
+ await expect(page.getByRole('alert')).toHaveCount(0)
+})
+
test('empty required fields show errors next to each field', async ({
page,
}) => {
diff --git a/frontend/src/components/LocationPicker.tsx b/frontend/src/components/LocationPicker.tsx
index 1f6ddf0..7db4d15 100644
--- a/frontend/src/components/LocationPicker.tsx
+++ b/frontend/src/components/LocationPicker.tsx
@@ -1,5 +1,12 @@
import L from 'leaflet'
-import { MapContainer, Marker, TileLayer, useMapEvents } from 'react-leaflet'
+import {
+ MapContainer,
+ Marker,
+ TileLayer,
+ useMap,
+ useMapEvents,
+} from 'react-leaflet'
+import { useEffect } from 'react'
import 'leaflet/dist/leaflet.css'
import MapLabel from './MapLabel'
import './LocationPicker.css'
@@ -20,6 +27,21 @@ const marker = L.icon({
interface LocationPickerProps {
position: [number, number] | null
onPositionChange: (position: [number, number]) => void
+ /**
+ * Moves the map to a place chosen outside it, such as from the place
+ * search. A new object moves it again, even to the same place.
+ */
+ showPlace?: { position: [number, number] } | null
+}
+
+// The map is only moved for places found by name. For a click the marker is
+// already under the pointer, and moving the map would shift it away.
+function MapMover({ target }: { target: { position: [number, number] } }) {
+ const map = useMap()
+ useEffect(() => {
+ map.flyTo(target.position, 10)
+ }, [map, target])
+ return null
}
function MapClickHandler({
@@ -31,7 +53,11 @@ function MapClickHandler({
return null
}
-function LocationPicker({ position, onPositionChange }: LocationPickerProps) {
+function LocationPicker({
+ position,
+ onPositionChange,
+ showPlace,
+}: LocationPickerProps) {
return (
+ {showPlace && }
{/* Clicking the marker does nothing, so it is not made a button. */}
{position && (
void
onChange: (field: keyof FormValues, value: string) => void
onPositionChange: (position: [number, number]) => void
+ onPlaceSelect: (result: PlaceResult) => void
+ shownPlace: { position: [number, number] } | null
lookupStatus: LookupStatus
isUnitedStates: boolean
}
@@ -25,6 +29,8 @@ function LocationSection({
onModeChange,
onChange,
onPositionChange,
+ onPlaceSelect,
+ shownPlace,
lookupStatus,
isUnitedStates,
}: LocationSectionProps) {
@@ -36,7 +42,7 @@ function LocationSection({
Where did it happen?
- Fill in the place, or click the map to fill it in for you.
+ Search for the place, click the map, or fill it in yourself.
@@ -63,6 +69,8 @@ function LocationSection({
+
+
Place names from{' '}
diff --git a/frontend/src/components/PlaceSearch.tsx b/frontend/src/components/PlaceSearch.tsx
new file mode 100644
index 0000000..0538d88
--- /dev/null
+++ b/frontend/src/components/PlaceSearch.tsx
@@ -0,0 +1,131 @@
+import { useId, useState, type KeyboardEvent } from 'react'
+import { usePlaceSearch } from '../hooks/use-place-search'
+import type { PlaceResult } from '../utils/place-search'
+
+interface PlaceSearchProps {
+ onSelect: (result: PlaceResult) => void
+}
+
+/**
+ * Finds a place by name, as a keyboard alternative to clicking the map. It
+ * follows the ARIA combobox pattern: focus stays in the input while the
+ * arrow keys move through the results, and Enter chooses one. Enter without
+ * a highlighted result, or the button, starts the search. It searches only
+ * on request, since Nominatim does not allow searching on every keystroke.
+ */
+function PlaceSearch({ onSelect }: PlaceSearchProps) {
+ const id = useId()
+ const listId = `${id}-results`
+ const hintId = `${id}-hint`
+ const [query, setQuery] = useState('')
+ const [chosen, setChosen] = useState()
+ const { status, results, activeIndex, search, moveActive, close } =
+ usePlaceSearch()
+ const open = status === 'done' && results.length > 0
+
+ function choose(result: PlaceResult) {
+ onSelect(result)
+ setChosen(result.name)
+ close()
+ }
+
+ function startSearch() {
+ setChosen(undefined)
+ void search(query)
+ }
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ if (!open) return
+ event.preventDefault()
+ moveActive(event.key === 'ArrowDown' ? 1 : -1)
+ } else if (event.key === 'Enter') {
+ // The input is inside the report form, which Enter would submit.
+ event.preventDefault()
+ if (open && activeIndex >= 0) choose(results[activeIndex])
+ else startSearch()
+ } else if (event.key === 'Escape' && (open || status === 'loading')) {
+ event.preventDefault()
+ close()
+ }
+ }
+
+ // Read out by screen readers when it changes, so it is always rendered.
+ let message = ''
+ if (status === 'loading') message = 'Searching…'
+ else if (status === 'failed') {
+ message = "Couldn't search for places. Try again, or click the map."
+ } else if (status === 'done' && results.length === 0) {
+ message = 'No places found. Try another name, or click the map.'
+ } else if (open) {
+ message = `${results.length} ${results.length === 1 ? 'place' : 'places'} found.`
+ } else if (chosen) {
+ message = `Location set to ${chosen}.`
+ }
+
+ return (
+