From c64f6ac89bbd8da4b43103fa613b5db2b6b20edb Mon Sep 17 00:00:00 2001 From: Asgeir Jacobsen Date: Wed, 4 Nov 2026 10:37:47 +0100 Subject: [PATCH 1/7] Feat: Clarify report a sighting form layout Group location fields with the map and add a mobile map/manual toggle. Update keyboard-flow and validation tests for the new form order. Closes #142 --- frontend/e2e/submit-sighting.spec.ts | 19 +- frontend/src/pages/SubmitSightingPage.css | 231 +++++++++-- frontend/src/pages/SubmitSightingPage.tsx | 379 +++++++++++------- .../src/tests/SubmitSightingPage.test.tsx | 11 +- 4 files changed, 444 insertions(+), 196 deletions(-) diff --git a/frontend/e2e/submit-sighting.spec.ts b/frontend/e2e/submit-sighting.spec.ts index 7b63a52..a307424 100644 --- a/frontend/e2e/submit-sighting.spec.ts +++ b/frontend/e2e/submit-sighting.spec.ts @@ -70,6 +70,14 @@ test('fill in and submit the form with the keyboard', async ({ page }) => { await page.keyboard.type('1045AM') await expect(fields.datetime).toHaveValue('2003-03-03T10:45') + // A closed select picks the option that starts with what is typed. + await tabTo(page, fields.shape) + await page.keyboard.type('sphere') + await expect(fields.shape).toHaveValue('sphere') + + await tabTo(page, fields.duration) + await page.keyboard.type('90') + await tabTo(page, fields.city) await page.keyboard.type('Trondheim') @@ -82,13 +90,6 @@ test('fill in and submit the form with the keyboard', async ({ page }) => { // Only the US has states. await expect(fields.state).toBeHidden() - // A closed select picks the option that starts with what is typed. - await tabTo(page, fields.shape) - await page.keyboard.type('sphere') - await expect(fields.shape).toHaveValue('sphere') - - await tabTo(page, fields.duration) - await page.keyboard.type('90') await tabTo(page, fields.latitude) await page.keyboard.type('63.4305') await tabTo(page, fields.longitude) @@ -181,10 +182,10 @@ test('empty required fields show errors next to each field', async ({ // "next to" the field for screen reader users. const expectedErrors: [Locator, string][] = [ [fields.datetime, 'Enter when you saw the object.'], - [fields.city, 'Enter the city.'], - [fields.country, 'Enter the country.'], [fields.shape, 'Choose the closest shape.'], [fields.duration, 'Enter a whole number of seconds, at least 1.'], + [fields.city, 'Enter the city.'], + [fields.country, 'Enter the country.'], [fields.latitude, 'Enter a latitude between -90 and 90.'], [fields.longitude, 'Enter a longitude between -180 and 180.'], [fields.description, 'Describe what you saw.'], diff --git a/frontend/src/pages/SubmitSightingPage.css b/frontend/src/pages/SubmitSightingPage.css index 5b65773..52f80e4 100644 --- a/frontend/src/pages/SubmitSightingPage.css +++ b/frontend/src/pages/SubmitSightingPage.css @@ -1,52 +1,48 @@ .submit-sighting { - max-width: 720px; + max-width: 860px; margin: 0 auto; } .submit-sighting__back { - color: var(--accent); - text-underline-offset: 4px; -} - -.submit-sighting h1 { - margin-bottom: 16px; -} - -.submit-sighting__intro { - margin-bottom: 28px; -} + justify-self: start; + padding: 7px 14px; + border-radius: 12px; + color: var(--accent-strong); + background: var(--accent-bg); + font-size: 15px; + font-weight: 600; + text-decoration: none; + transition: background-color 0.15s; -.submit-sighting__location { - display: grid; - gap: 12px; -} + &:hover { + background: var(--accent-bg-strong); + } -.submit-sighting__location h2 { - margin-bottom: 4px; -} + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } -.submit-sighting__lookup, -.submit-sighting__attribution { - margin: 0; - font-size: 0.9em; -} + @media (forced-colors: active) { + border: 1px solid LinkText; + } -/* Keeps the space when empty, so the page doesn't jump as it changes. */ -.submit-sighting__lookup { - min-height: 1.5em; + @media (prefers-reduced-motion: reduce) { + transition: none; + } } -.submit-sighting__lookup--failed { - color: var(--error); +.submit-sighting h1 { + margin-bottom: 24px; } -.submit-sighting__attribution a { - color: var(--accent); +.submit-sighting__intro { + margin-bottom: 24px; } .submit-sighting__form { display: grid; - gap: 20px; + gap: 24px; } .submit-sighting__row { @@ -71,7 +67,7 @@ box-sizing: border-box; width: 100%; border: 1px solid var(--border); - border-radius: 4px; + border-radius: 8px; padding: 10px 12px; color: var(--text-h); background: var(--bg); @@ -107,15 +103,98 @@ font-weight: 600; } +/* Where did it happen? section */ +.submit-sighting__location { + display: grid; + gap: 16px; + padding: 24px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--code-bg); +} + +.submit-sighting__location-header h2 { + margin: 0 0 4px; + font-size: 20px; + font-weight: 600; + color: var(--text-h); +} + +.submit-sighting__hint { + margin: 0; + color: var(--text); + font-size: 15px; +} + +.submit-sighting__location-toggle { + display: none; +} + +.submit-sighting__location-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); + gap: 24px; + align-items: start; +} + +.submit-sighting__place-fields { + display: grid; + gap: 16px; +} + +.submit-sighting__row--coordinates { + gap: 12px; +} + +.submit-sighting__map-side { + display: grid; + gap: 10px; +} + +.submit-sighting__map-side .location-picker { + border-radius: 10px; + box-shadow: var(--shadow); +} + +.submit-sighting__picked-summary { + margin: 0; + font-size: 0.95em; + color: var(--text-h); +} + +.submit-sighting__lookup, +.submit-sighting__attribution { + margin: 0; + font-size: 0.9em; +} + +.submit-sighting__lookup { + min-height: 1.5em; +} + +.submit-sighting__lookup--failed { + color: var(--error); +} + +.submit-sighting__attribution a { + color: var(--accent); +} + +.submit-sighting__attribution { + justify-self: end; + text-align: right; +} + .submit-sighting__button, .submit-sighting button { justify-self: start; border: 0; - border-radius: 4px; + border-radius: 8px; padding: 11px 18px; color: var(--on-accent); background: var(--accent); font: inherit; + font-weight: 600; cursor: pointer; text-decoration: none; } @@ -136,8 +215,92 @@ background: var(--border); } +@media (min-width: 641px) { + .submit-sighting__picked-summary { + display: none; + } +} + @media (max-width: 640px) { + .submit-sighting__location { + padding: 16px; + } + .submit-sighting__row { grid-template-columns: 1fr; } + + .submit-sighting__row--coordinates { + grid-template-columns: 1fr 1fr; + } + + .submit-sighting__location-toggle { + display: flex; + gap: 8px; + } + + .submit-sighting__toggle-btn { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text-h); + font: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + text-align: center; + transition: + background-color 0.15s, + color 0.15s; + + &:hover { + background: var(--accent-bg); + } + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + @media (forced-colors: active) { + border: 1px solid ButtonText; + } + } + + .submit-sighting__toggle-btn--active { + background: var(--accent); + color: var(--on-accent); + border-color: var(--accent); + + &:hover { + background: var(--accent); + } + + @media (forced-colors: active) { + background: Highlight; + color: HighlightText; + } + } + + .submit-sighting__location-grid { + grid-template-columns: 1fr; + } + + .submit-sighting__location-grid[data-mode='map'] + .submit-sighting__place-fields { + display: none; + } + + .submit-sighting__location-grid[data-mode='manual'] + .submit-sighting__map-side { + display: none; + } +} + +@media (max-width: 400px) { + .submit-sighting__row--coordinates { + grid-template-columns: 1fr; + } } diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx index 483d020..8af0845 100644 --- a/frontend/src/pages/SubmitSightingPage.tsx +++ b/frontend/src/pages/SubmitSightingPage.tsx @@ -125,6 +125,7 @@ function SubmitSightingPage() { 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 { data: shapeData, @@ -157,7 +158,7 @@ function SubmitSightingPage() { if (!focusFirstError.current) return focusFirstError.current = false formRef.current - ?.querySelector('[aria-invalid="true"]') + ?.querySelector('[aria-invalid="true"]:not(:disabled)') ?.focus() }, [errors]) @@ -210,6 +211,15 @@ function SubmitSightingPage() { // 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 } @@ -243,6 +253,15 @@ function SubmitSightingPage() { } 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 { @@ -273,6 +292,7 @@ function SubmitSightingPage() { onClick={() => { setValues(initialValues) lookup.reset() + setLocationMode('map') focusHeading.current = true setSubmittedId(undefined) }} @@ -322,72 +342,6 @@ function SubmitSightingPage() {

)} -
- - updateValue('city', event.target.value)} - aria-invalid={Boolean(errors.city)} - aria-describedby={errors.city ? 'sighting-city-error' : undefined} - required - /> - {errors.city && ( -

- {errors.city} -

- )} -
-
-
- - {/* 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 - /> - - {countries.map(({ code, name }) => ( - - {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 && ( -
- - updateValue('state', event.target.value)} - /> -
- )} -
-
-
- - updateValue('latitude', event.target.value)} - aria-invalid={Boolean(errors.latitude)} - aria-describedby={ - errors.latitude ? 'sighting-latitude-error' : undefined - } - required - /> - {errors.latitude && ( -

- {errors.latitude} -

- )} +
+
+

Where did it happen?

+

+ Fill in the place, or click the map to fill it in for you. +

-
- - updateValue('longitude', event.target.value)} - aria-invalid={Boolean(errors.longitude)} - aria-describedby={ - errors.longitude ? 'sighting-longitude-error' : undefined - } - required - /> - {errors.longitude && ( + +
+ + +
+ +
+
+
+ + updateValue('city', event.target.value)} + aria-invalid={Boolean(errors.city)} + aria-describedby={ + errors.city ? 'sighting-city-error' : undefined + } + required + /> + {errors.city && ( +

+ {errors.city} +

+ )} +
+
+ + {/* 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 + /> + + {countries.map(({ code, name }) => ( + + {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 && ( +
+ + + updateValue('state', event.target.value) + } + /> +
+ )} +
+
+ + + updateValue('latitude', event.target.value) + } + aria-invalid={Boolean(errors.latitude)} + aria-describedby={ + errors.latitude ? 'sighting-latitude-error' : undefined + } + required + /> + {errors.latitude && ( +

+ {errors.latitude} +

+ )} +
+
+ + + updateValue('longitude', event.target.value) + } + aria-invalid={Boolean(errors.longitude)} + aria-describedby={ + errors.longitude ? 'sighting-longitude-error' : undefined + } + required + /> + {errors.longitude && ( +

+ {errors.longitude} +

+ )} +
+
+
+
+ +

+ Place names from{' '} + + OpenStreetMap + {' '} + via Nominatim. +

+ {(values.city || (values.latitude && values.longitude)) && ( +

+ Pinned location:{' '} + + {values.city || `${values.latitude}, ${values.longitude}`} + {values.state ? `, ${values.state}` : ''} + {values.country ? `, ${values.country}` : ''} + +

+ )} + {/* 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. -

-
+
- - updateValue('durationSeconds', event.target.value) - } - aria-invalid={Boolean(errors.durationSeconds)} - aria-describedby={ - errors.durationSeconds ? 'sighting-duration-error' : undefined - } - required - /> - {errors.durationSeconds && ( +
+ + updateValue('duration', event.target.value) + } + aria-invalid={Boolean(errors.duration)} + aria-describedby={ + errors.duration ? 'sighting-duration-error' : undefined + } + required + /> + +
+ {errors.duration && (

- {errors.durationSeconds} + {errors.duration}

)}
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 + /> + + {countries.map(({ code, name }) => ( + + + {/* 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)} + /> + + )} +
+ + onChange('latitude', event.target.value)} + required + /> + + + onChange('longitude', event.target.value)} + required + /> + +
+
+
+ +

+ Place names from{' '} + OpenStreetMap{' '} + via Nominatim. +

+ {(values.city || (values.latitude && values.longitude)) && ( +

+ Pinned location:{' '} + + {values.city || `${values.latitude}, ${values.longitude}`} + {values.state ? `, ${values.state}` : ''} + {values.country ? `, ${values.country}` : ''} + +

+ )} + {/* 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() {
-
- + updateValue('datetime', event.target.value)} - // Tell the user as soon as they leave the field. Typing clears the message again (see updateValue). - onBlur={(event) => { - const message = event.target.value - ? datetimeError(event.target.value) - : undefined - if (message) { - setErrors((previous) => ({ ...previous, datetime: message })) - } - }} - aria-invalid={Boolean(errors.datetime)} - aria-describedby={ - errors.datetime ? 'sighting-datetime-error' : undefined + onChange={(event) => + form.updateValue('datetime', event.target.value) } + onBlur={(event) => form.checkDatetime(event.target.value)} required /> - {errors.datetime && ( -

- {errors.datetime} -

- )} -
+
-
- + - updateValue('duration', event.target.value) - } - aria-invalid={Boolean(errors.duration)} - aria-describedby={ - errors.duration ? 'sighting-duration-error' : undefined + form.updateValue('duration', event.target.value) } required /> @@ -466,7 +154,7 @@ function SubmitSightingPage() { aria-label="Unit" value={values.durationUnit} onChange={(event) => - setValues((previous) => ({ + form.setValues((previous) => ({ ...previous, durationUnit: event.target.value as DurationUnit, })) @@ -479,258 +167,41 @@ function SubmitSightingPage() { ))}
- {errors.duration && ( -

- {errors.duration} -

- )} -
+
-
+ -
-

Where did it happen?

-

- Fill in the place, or click the map to fill it in for you. -

-
- -
- - -
- -
-
-
- - updateValue('city', event.target.value)} - aria-invalid={Boolean(errors.city)} - aria-describedby={ - errors.city ? 'sighting-city-error' : undefined - } - required - /> - {errors.city && ( -

- {errors.city} -

- )} -
-
- - {/* 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 - /> - - {countries.map(({ code, name }) => ( - - {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 && ( -
- - - updateValue('state', event.target.value) - } - /> -
- )} -
-
- - - updateValue('latitude', event.target.value) - } - aria-invalid={Boolean(errors.latitude)} - aria-describedby={ - errors.latitude ? 'sighting-latitude-error' : undefined - } - required - /> - {errors.latitude && ( -

- {errors.latitude} -

- )} -
-
- - - updateValue('longitude', event.target.value) - } - aria-invalid={Boolean(errors.longitude)} - aria-describedby={ - errors.longitude ? 'sighting-longitude-error' : undefined - } - required - /> - {errors.longitude && ( -

- {errors.longitude} -

- )} -
-
-
-
- -

- Place names from{' '} - - OpenStreetMap - {' '} - via Nominatim. -

- {(values.city || (values.latitude && values.longitude)) && ( -

- Pinned location:{' '} - - {values.city || `${values.latitude}, ${values.longitude}`} - {values.state ? `, ${values.state}` : ''} - {values.country ? `, ${values.country}` : ''} - -

- )} - {/* 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."} -

-
-
-
-
-