diff --git a/AGENTS.md b/AGENTS.md index 5a1a893..6e2887f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,17 +65,18 @@ npm run test frontend/ src/ components/ Reusable UI (Banner, SearchBar, SightingList, SightingMap, BackendStatus) - pages/ Route-level components (SearchPage, SightingDetailPage) + pages/ Route-level components (SearchPage, SightingDetailPage, SubmitSightingPage) hooks/ Custom hooks (debounce, sighting search, single sighting) utils/ Pure helpers (filtering, formatting) types/ Shared TypeScript types data/ Mock sightings (to be replaced by GraphQL) - graphql/ Apollo Client setup (client.ts) and GraphQL queries (queries.ts) + graphql/ Apollo Client setup (client.ts), queries.ts and mutations.ts backend/ Node + TypeScript GraphQL server (Apollo Server 5) src/ index.ts Starts the server on PORT (default 3001) schema.ts GraphQL type definitions and resolvers - # db/ Planned: MySQL connection and queries + validate-sighting.ts Server-side validation for createSighting + db/ MySQL connection pool and sighting queries dist/ Build output from `npm run build` (git-ignored) # docs/ ``` diff --git a/README.md b/README.md index b370247..855c55d 100644 --- a/README.md +++ b/README.md @@ -111,14 +111,18 @@ browser. ## GraphQL API Design -The schema is in `backend/src/schema.ts`. It is a proposal: the resolvers and -database connection are not written yet, so only `health` returns data. +The schema is in `backend/src/schema.ts`. - `sighting(id)` returns one sighting, or null if it does not exist. - `sightings(search, filter, sort, first, after)` returns a page of sightings with `totalCount` and `pageInfo`. Search matches city, shape and comments. Filters are shape, country, state and a date range. Sort is date (newest or oldest), city or shape. All of it runs in the database. +- `createSighting(input)` stores a user-submitted sighting and returns it with + `userSubmitted: true`. The server checks required fields, text length, + coordinate ranges and that the date is real and not in the future. Invalid + input fails with code `BAD_USER_INPUT` and a `fieldErrors` list, so the form + can show each message next to its field. **Pagination: cursor based "load more".** With about 80,000 rows, `LIMIT/OFFSET` gets slower the further you page, while a cursor continues from where the last diff --git a/backend/db-design/create-tables.sql b/backend/db-design/create-tables.sql index 7904cdc..3653143 100644 --- a/backend/db-design/create-tables.sql +++ b/backend/db-design/create-tables.sql @@ -53,6 +53,10 @@ CREATE TABLE IF NOT EXISTS UFO_SIGHTINGS ( LATITUDE DECIMAL(9,6) NULL, LONGITUDE DECIMAL(9,6) NULL, CREATED_BY_USER_ID INT NULL, + -- TRUE for rows created through the createSighting mutation, FALSE for + -- rows imported from the NUFORC dataset. Kept separate from + -- CREATED_BY_USER_ID because sightings can be submitted without login. + IS_USER_SUBMITTED BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (UFO_SIGHTING_ID), KEY IDX_SIGHTINGS_DATETIME (SIGHTING_DATETIME), KEY IDX_SIGHTINGS_LOCATION (LOCATION_ID), diff --git a/backend/package.json b/backend/package.json index 3a0d6b6..4501772 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,8 @@ "dev": "tsx watch --env-file-if-exists=.env src/index.ts", "build": "tsc -p .", "start": "node --env-file-if-exists=.env dist/index.js", - "lint": "eslint ." + "lint": "eslint .", + "test": "node --test \"src/**/*.test.ts\"" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/backend/src/db/sightings.ts b/backend/src/db/sightings.ts index 4904699..ca259a7 100644 --- a/backend/src/db/sightings.ts +++ b/backend/src/db/sightings.ts @@ -1,4 +1,5 @@ -import type { RowDataPacket } from 'mysql2/promise' +import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise' +import type { NewSighting } from '../validate-sighting.ts' import { pool } from './pool.ts' export interface SightingsArgs { @@ -32,7 +33,8 @@ const SELECT = ` s.COMMENTS AS comments, s.DATE_POSTED AS datePosted, s.LATITUDE AS latitude, - s.LONGITUDE AS longitude` + s.LONGITUDE AS longitude, + s.IS_USER_SUBMITTED AS userSubmitted` // Looked up here, never built from user input, so it cannot inject SQL. // The id breaks ties so pages stay stable when values repeat. @@ -50,6 +52,8 @@ function toNode(row: RowDataPacket) { id: String(row.id), datetime: row.datetime?.replace(' ', 'T') ?? null, datePosted: row.datePosted?.replace(' ', 'T') ?? null, + // BOOLEAN is TINYINT(1) in MySQL, so it arrives as 0 or 1. + userSubmitted: Boolean(row.userSubmitted), } } @@ -134,3 +138,68 @@ export async function findSightingById(id: string) { ) return rows[0] ? toNode(rows[0]) : null } + +/** + * Stores a validated user-submitted sighting and returns it as the API + * shows it. Runs in one transaction so a failed insert does not leave a + * new location or shape behind without a sighting. + */ +export async function insertSighting(sighting: NewSighting) { + const connection = await pool.getConnection() + try { + await connection.beginTransaction() + + // Matches the import script: STATE and COUNTRY can be NULL, and the + // unique key does not treat NULLs as equal, so look up with <=> first. + const [locations] = await connection.query( + `SELECT LOCATION_ID FROM LOCATIONS + WHERE CITY = ? AND STATE <=> ? AND COUNTRY <=> ? LIMIT 1`, + [sighting.city, sighting.state, sighting.country], + ) + let locationId: number = locations[0]?.LOCATION_ID + if (locationId === undefined) { + // LAST_INSERT_ID(col) makes insertId the existing id if another + // request created the same location in the meantime. + const [created] = await connection.query( + `INSERT INTO LOCATIONS (CITY, STATE, COUNTRY) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE LOCATION_ID = LAST_INSERT_ID(LOCATION_ID)`, + [sighting.city, sighting.state, sighting.country], + ) + locationId = created.insertId + } + + const [shape] = await connection.query( + `INSERT INTO SHAPES (NAME) VALUES (?) + ON DUPLICATE KEY UPDATE SHAPE_ID = LAST_INSERT_ID(SHAPE_ID)`, + [sighting.shape], + ) + + const [inserted] = await connection.query( + `INSERT INTO UFO_SIGHTINGS (SIGHTING_DATETIME, LOCATION_ID, SHAPE_ID, + DURATION_SECONDS, COMMENTS, DATE_POSTED, LATITUDE, LONGITUDE, + IS_USER_SUBMITTED) + VALUES (?, ?, ?, ?, ?, NOW(), ?, ?, TRUE)`, + [ + sighting.datetime, + locationId, + shape.insertId, + sighting.durationSeconds, + sighting.comments, + sighting.latitude, + sighting.longitude, + ], + ) + + const [rows] = await connection.query( + `${SELECT} ${FROM} WHERE s.UFO_SIGHTING_ID = ?`, + [inserted.insertId], + ) + await connection.commit() + return toNode(rows[0]) + } catch (error) { + await connection.rollback() + throw error + } finally { + connection.release() + } +} diff --git a/backend/src/schema.ts b/backend/src/schema.ts index bd65513..85de207 100644 --- a/backend/src/schema.ts +++ b/backend/src/schema.ts @@ -1,8 +1,14 @@ +import { GraphQLError } from 'graphql' import { findSightingById, findSightings, + insertSighting, type SightingsArgs, } from './db/sightings.ts' +import { + validateCreateSighting, + type CreateSightingInput, +} from './validate-sighting.ts' export const typeDefs = `#graphql "A single UFO sighting report." @@ -19,6 +25,8 @@ export const typeDefs = `#graphql datePosted: String latitude: Float longitude: Float + "True if a user submitted it through the app, false if it is from the NUFORC dataset." + userSubmitted: Boolean! } enum SightingSort { @@ -57,6 +65,24 @@ export const typeDefs = `#graphql totalCount: Int! } + "A new sighting from a user. Validated on the server, see createSighting." + input CreateSightingInput { + "Local time where it was seen, no timezone, e.g. 2005-01-01T00:30. Not in the future." + datetime: String! + city: String! + state: String + country: String! + shape: String! + "1 to 31536000 (one year)." + durationSeconds: Int! + "1-2000 characters." + comments: String! + "-90 to 90." + latitude: Float! + "-180 to 180." + longitude: Float! + } + type Query { "Returns \\"ok\\" when the server is up." health: String! @@ -73,6 +99,15 @@ export const typeDefs = `#graphql after: String ): SightingConnection! } + + type Mutation { + """ + Stores a user-submitted sighting and returns it. Invalid input fails + with code BAD_USER_INPUT and a \`fieldErrors\` list of { field, message } + in the error extensions. + """ + createSighting(input: CreateSightingInput!): Sighting! + } ` export const resolvers = { @@ -82,4 +117,18 @@ export const resolvers = { findSightingById(args.id), sightings: (_parent: unknown, args: SightingsArgs) => findSightings(args), }, + Mutation: { + createSighting: ( + _parent: unknown, + { input }: { input: CreateSightingInput }, + ) => { + const result = validateCreateSighting(input) + if (!result.ok) { + throw new GraphQLError('The sighting has invalid fields.', { + extensions: { code: 'BAD_USER_INPUT', fieldErrors: result.errors }, + }) + } + return insertSighting(result.value) + }, + }, } diff --git a/backend/src/validate-sighting.test.ts b/backend/src/validate-sighting.test.ts new file mode 100644 index 0000000..db8be62 --- /dev/null +++ b/backend/src/validate-sighting.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + validateCreateSighting, + type CreateSightingInput, +} from './validate-sighting.ts' + +// Fixed clock so "not in the future" does not depend on when tests run. +const now = new Date('2026-06-15T12:00:00Z') + +const valid: CreateSightingInput = { + datetime: '2024-03-10T21:15', + city: ' Trondheim ', + state: '', + country: 'NO', + shape: 'Light', + durationSeconds: 120, + comments: 'Bright light hovering over the fjord.', + latitude: 63.43, + longitude: 10.39, +} + +function errorFields(input: CreateSightingInput): string[] { + const result = validateCreateSighting(input, now) + return result.ok ? [] : result.errors.map((error) => error.field) +} + +describe('validateCreateSighting', () => { + it('accepts valid input and normalizes it', () => { + const result = validateCreateSighting(valid, now) + assert.deepEqual(result, { + ok: true, + value: { + datetime: '2024-03-10T21:15:00', + city: 'trondheim', + state: null, + country: 'no', + shape: 'light', + durationSeconds: 120, + comments: 'Bright light hovering over the fjord.', + latitude: 63.43, + longitude: 10.39, + }, + }) + }) + + it('rejects blank required text fields', () => { + assert.deepEqual( + errorFields({ + ...valid, + city: ' ', + country: '', + shape: '', + comments: '', + }), + ['city', 'country', 'shape', 'comments'], + ) + }) + + it('rejects text longer than the database columns', () => { + assert.deepEqual(errorFields({ ...valid, shape: 'x'.repeat(101) }), [ + 'shape', + ]) + assert.deepEqual(errorFields({ ...valid, comments: 'x'.repeat(2001) }), [ + 'comments', + ]) + }) + + it('rejects coordinates out of range', () => { + assert.deepEqual(errorFields({ ...valid, latitude: 90.1 }), ['latitude']) + assert.deepEqual(errorFields({ ...valid, longitude: -180.1 }), [ + 'longitude', + ]) + }) + + it('accepts coordinates on the boundary', () => { + assert.deepEqual( + errorFields({ ...valid, latitude: -90, longitude: 180 }), + [], + ) + }) + + it('rejects durations outside 1 second to 1 year', () => { + assert.deepEqual(errorFields({ ...valid, durationSeconds: 0 }), [ + 'durationSeconds', + ]) + assert.deepEqual(errorFields({ ...valid, durationSeconds: 31_536_001 }), [ + 'durationSeconds', + ]) + }) + + it('rejects malformed and impossible datetimes', () => { + for (const datetime of [ + '2024-03-10', + '2024-03-10 21:15', + '2024-03-10T21:15Z', + '2023-02-29T10:00', + '2024-13-01T10:00', + '2024-03-10T24:00', + ]) { + assert.deepEqual( + errorFields({ ...valid, datetime }), + ['datetime'], + datetime, + ) + } + }) + + it('rejects datetimes in the future or before 1900', () => { + assert.deepEqual(errorFields({ ...valid, datetime: '2026-06-17T00:00' }), [ + 'datetime', + ]) + assert.deepEqual(errorFields({ ...valid, datetime: '1899-12-31T23:59' }), [ + 'datetime', + ]) + }) + + it('allows a local time slightly ahead of the server clock', () => { + // 12:00 UTC is 23:00 in UTC+11, so this is the past for that observer. + assert.deepEqual( + errorFields({ ...valid, datetime: '2026-06-15T23:00' }), + [], + ) + }) + + it('reports every invalid field at once', () => { + assert.deepEqual( + errorFields({ ...valid, city: '', latitude: 200, durationSeconds: -5 }), + ['city', 'durationSeconds', 'latitude'], + ) + }) +}) diff --git a/backend/src/validate-sighting.ts b/backend/src/validate-sighting.ts new file mode 100644 index 0000000..583b9bf --- /dev/null +++ b/backend/src/validate-sighting.ts @@ -0,0 +1,175 @@ +/** Arguments of the `createSighting` mutation, as GraphQL delivers them. */ +export interface CreateSightingInput { + datetime: string + city: string + state?: string | null + country: string + shape: string + durationSeconds: number + comments: string + latitude: number + longitude: number +} + +/** Input that passed validation, trimmed and ready to store. */ +export interface NewSighting { + datetime: string + city: string + state: string | null + country: string + shape: string + durationSeconds: number + comments: string + latitude: number + longitude: number +} + +export interface FieldError { + field: keyof CreateSightingInput + message: string +} + +export type ValidationResult = + { ok: true; value: NewSighting } | { ok: false; errors: FieldError[] } + +// Text limits match the VARCHAR sizes in db-design/create-tables.sql, so +// valid input never fails on insert. +const MAX_PLACE_LENGTH = 255 +const MAX_SHAPE_LENGTH = 100 +const MAX_COMMENTS_LENGTH = 2000 +const MAX_DURATION_SECONDS = 365 * 24 * 60 * 60 +const EARLIEST_YEAR = 1900 +// The datetime is local to the observer and has no timezone, so "now" +// can be up to UTC+14 ahead of the server clock. +const MAX_TIMEZONE_AHEAD_MS = 14 * 60 * 60 * 1000 + +const LOCAL_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/ + +/** + * Checks a user-submitted sighting and collects every problem at once, so + * the form can show all field errors together instead of one per submit. + */ +export function validateCreateSighting( + input: CreateSightingInput, + now: Date = new Date(), +): ValidationResult { + const errors: FieldError[] = [] + + const requireText = ( + field: 'city' | 'country' | 'shape' | 'comments', + maxLength: number, + ): string => { + const value = input[field].trim() + if (value === '') { + errors.push({ field, message: `${field} is required.` }) + } else if (value.length > maxLength) { + errors.push({ + field, + message: `${field} must be at most ${maxLength} characters.`, + }) + } + return value + } + + const datetime = normalizeDatetime(input.datetime, now) + if (typeof datetime !== 'string') { + errors.push({ field: 'datetime', message: datetime.error }) + } + + // The import script stores places and shapes in lowercase. Doing the + // same keeps submitted rows consistent with the dataset in the API. + const city = requireText('city', MAX_PLACE_LENGTH).toLowerCase() + const country = requireText('country', MAX_PLACE_LENGTH).toLowerCase() + const shape = requireText('shape', MAX_SHAPE_LENGTH).toLowerCase() + const comments = requireText('comments', MAX_COMMENTS_LENGTH) + + // An empty state is treated as "no state", the same as in the imported data. + const state = input.state?.trim().toLowerCase() || null + if (state !== null && state.length > MAX_PLACE_LENGTH) { + errors.push({ + field: 'state', + message: `state must be at most ${MAX_PLACE_LENGTH} characters.`, + }) + } + + if ( + input.durationSeconds < 1 || + input.durationSeconds > MAX_DURATION_SECONDS + ) { + errors.push({ + field: 'durationSeconds', + message: `durationSeconds must be between 1 and ${MAX_DURATION_SECONDS}.`, + }) + } + + if (!(input.latitude >= -90 && input.latitude <= 90)) { + errors.push({ + field: 'latitude', + message: 'latitude must be between -90 and 90.', + }) + } + if (!(input.longitude >= -180 && input.longitude <= 180)) { + errors.push({ + field: 'longitude', + message: 'longitude must be between -180 and 180.', + }) + } + + if (errors.length > 0 || typeof datetime !== 'string') { + return { ok: false, errors } + } + + return { + ok: true, + value: { + datetime, + city, + state, + country, + shape, + durationSeconds: input.durationSeconds, + comments, + latitude: input.latitude, + longitude: input.longitude, + }, + } +} + +/** + * Returns the datetime with seconds filled in, so all stored values share + * the `Sighting.datetime` format, or an error message. + */ +function normalizeDatetime(raw: string, now: Date): string | { error: string } { + const match = LOCAL_DATETIME.exec(raw.trim()) + if (!match) { + return { error: 'datetime must look like 2005-01-01T00:30 (local time).' } + } + + const [, year, month, day, hour, minute, second = '00'] = match + const parts = [year, month, day, hour, minute, second].map(Number) + const [y, mo, d, h, mi, s] = parts + + // Date.UTC rolls invalid values over (Feb 30 becomes Mar 2), so a + // round trip that changes any part means the input was not a real time. + const asUtc = new Date(Date.UTC(y, mo - 1, d, h, mi, s)) + const roundTrip = [ + asUtc.getUTCFullYear(), + asUtc.getUTCMonth() + 1, + asUtc.getUTCDate(), + asUtc.getUTCHours(), + asUtc.getUTCMinutes(), + asUtc.getUTCSeconds(), + ] + if (roundTrip.some((value, i) => value !== parts[i])) { + return { error: 'datetime is not a valid date and time.' } + } + + if (y < EARLIEST_YEAR) { + return { error: `datetime must be in ${EARLIEST_YEAR} or later.` } + } + if (asUtc.getTime() > now.getTime() + MAX_TIMEZONE_AHEAD_MS) { + return { error: 'datetime cannot be in the future.' } + } + + return `${year}-${month}-${day}T${hour}:${minute}:${second}` +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 910da94..209d204 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -24,5 +24,7 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + /* Tests run straight from source with `node --test`, not from dist/ */ + "exclude": ["src/**/*.test.ts"] } diff --git a/frontend/src/data/sighting-store.ts b/frontend/src/data/sighting-store.ts index af5128c..7b8c35b 100644 --- a/frontend/src/data/sighting-store.ts +++ b/frontend/src/data/sighting-store.ts @@ -13,13 +13,12 @@ export function subscribeToSightings(listener: () => void): () => void { return () => listeners.delete(listener) } -export function addSighting(sighting: Omit) { - const newSighting: UfoSighting = { - ...sighting, - id: crypto.randomUUID(), - datePosted: new Date().toISOString().slice(0, 10), - } - sightings = [newSighting, ...sightings] +/** + * Adds a sighting the backend has already stored. The list and detail + * pages still read from this store, so without it a new sighting would + * not show up until those pages fetch from GraphQL. + */ +export function addSighting(sighting: UfoSighting) { + sightings = [sighting, ...sightings] listeners.forEach((listener) => listener()) - return newSighting } diff --git a/frontend/src/graphql/mutations.ts b/frontend/src/graphql/mutations.ts new file mode 100644 index 0000000..ee4676f --- /dev/null +++ b/frontend/src/graphql/mutations.ts @@ -0,0 +1,68 @@ +import { gql, type TypedDocumentNode } from '@apollo/client' + +/** Matches `CreateSightingInput` in backend/src/schema.ts. */ +export interface CreateSightingInput { + /** Local time without timezone, as a datetime-local input gives it. */ + datetime: string + city: string + state: string | null + country: string + shape: string + durationSeconds: number + comments: string + latitude: number + longitude: number +} + +export interface CreatedSighting { + id: string + datetime: string | null + city: string | null + state: string | null + country: string | null + shape: string | null + durationSeconds: number | null + comments: string | null + datePosted: string | null + latitude: number | null + longitude: number | null +} + +interface CreateSightingData { + createSighting: CreatedSighting +} + +interface CreateSightingVariables { + input: CreateSightingInput +} + +/** One entry of `extensions.fieldErrors` on a BAD_USER_INPUT error. */ +export interface SightingFieldError { + field: keyof CreateSightingInput + message: string +} + +/** + * Stores a user-submitted sighting. The server validates the input again + * and rejects it with BAD_USER_INPUT and `fieldErrors` if anything is off. + */ +export const CREATE_SIGHTING_MUTATION: TypedDocumentNode< + CreateSightingData, + CreateSightingVariables +> = gql` + mutation CreateSighting($input: CreateSightingInput!) { + createSighting(input: $input) { + id + datetime + city + state + country + shape + durationSeconds + comments + datePosted + latitude + longitude + } + } +` diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx index 09bb19b..920dfe8 100644 --- a/frontend/src/pages/SubmitSightingPage.tsx +++ b/frontend/src/pages/SubmitSightingPage.tsx @@ -1,6 +1,13 @@ +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' import './SubmitSightingPage.css' @@ -40,8 +47,8 @@ function validate(values: FormValues): FormErrors { if (!values.city.trim()) errors.city = 'Enter the city.' if (!values.country.trim()) errors.country = 'Enter the country code.' if (!values.shape) errors.shape = 'Choose the closest shape.' - if (!values.durationSeconds || !Number.isFinite(duration) || duration < 0) { - errors.durationSeconds = 'Enter a duration of zero seconds or more.' + if (!values.durationSeconds || !Number.isInteger(duration) || duration < 1) { + errors.durationSeconds = 'Enter a whole number of seconds, at least 1.' } if (!values.comments.trim()) errors.comments = 'Describe what you saw.' if ( @@ -63,10 +70,51 @@ function validate(values: FormValues): FormErrors { 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) { + errors[field] = message + } + 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({}) const [submittedId, setSubmittedId] = useState() + const [saveFailed, setSaveFailed] = useState(false) + const [createSighting, { loading }] = useMutation(CREATE_SIGHTING_MUTATION) function updateValue(field: keyof FormValues, value: string) { setValues((previous) => ({ ...previous, [field]: value })) @@ -78,24 +126,42 @@ function SubmitSightingPage() { updateValue('longitude', longitude.toFixed(6)) } - function handleSubmit(event: FormEvent) { + async function handleSubmit(event: FormEvent) { event.preventDefault() + setSaveFailed(false) const nextErrors = validate(values) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return - const sighting = addSighting({ - datetime: new Date(values.datetime).toISOString(), - city: values.city.trim(), - state: values.state.trim(), - country: values.country.trim().toLowerCase(), - shape: values.shape, - durationSeconds: Number(values.durationSeconds), - comments: values.comments.trim(), - latitude: Number(values.latitude), - longitude: Number(values.longitude), - }) - setSubmittedId(sighting.id) + try { + const { data } = await createSighting({ + variables: { + input: { + // datetime-local gives local time without timezone, which is + // the format the API stores. + datetime: values.datetime, + city: values.city.trim(), + state: values.state.trim() || null, + country: values.country.trim(), + shape: values.shape, + durationSeconds: Number(values.durationSeconds), + comments: values.comments.trim(), + latitude: Number(values.latitude), + longitude: Number(values.longitude), + }, + }, + }) + if (!data) throw new Error('createSighting returned no data') + addSighting(toUfoSighting(data.createSighting)) + setSubmittedId(data.createSighting.id) + } catch (error) { + const fieldErrors = serverFieldErrors(error) + if (fieldErrors) { + setErrors(fieldErrors) + } else { + setSaveFailed(true) + } + } } if (submittedId) { @@ -169,6 +235,7 @@ function SubmitSightingPage() { updateValue('city', event.target.value)} aria-invalid={Boolean(errors.city)} @@ -187,6 +254,7 @@ function SubmitSightingPage() { updateValue('state', event.target.value)} /> @@ -260,8 +328,8 @@ function SubmitSightingPage() { updateValue('durationSeconds', event.target.value) @@ -361,6 +429,7 @@ function SubmitSightingPage() {