TripFormDialog.tsx
4,461 bytes
| 1 | import { useState, type FormEvent } from "react"; |
|---|---|
| 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { LoaderCircle, X } from "lucide-react"; |
| 4 | import { api, ApiError, jsonBody } from "../lib/api"; |
| 5 | import { todayInput } from "../lib/format"; |
| 6 | import { useModalDialog } from "../lib/useModalDialog"; |
| 7 | import type { Destination, SaveTripInput, Trip } from "../types"; |
| 8 | |
| 9 | function dateAfter(days: number) { |
| 10 | const date = new Date(); |
| 11 | date.setDate(date.getDate() + days); |
| 12 | const offset = date.getTimezoneOffset(); |
| 13 | return new Date(date.getTime() - offset * 60_000).toISOString().slice(0, 10); |
| 14 | } |
| 15 | |
| 16 | export function TripFormDialog({ trip, onClose }: { trip?: Trip; onClose: () => void }) { |
| 17 | const dialogRef = useModalDialog(onClose); |
| 18 | const queryClient = useQueryClient(); |
| 19 | const [destinationCode, setDestinationCode] = useState(trip?.destination.code ?? ""); |
| 20 | const [city, setCity] = useState(trip?.city ?? ""); |
| 21 | const [startsOn, setStartsOn] = useState(trip?.startsOn ?? dateAfter(14)); |
| 22 | const [endsOn, setEndsOn] = useState(trip?.endsOn ?? dateAfter(20)); |
| 23 | const [error, setError] = useState(""); |
| 24 | const destinations = useQuery({ |
| 25 | queryKey: ["destinations"], |
| 26 | queryFn: () => api<Destination[]>("/api/v1/catalog/destinations") |
| 27 | }); |
| 28 | |
| 29 | const selectedDestinationCode = destinationCode || destinations.data?.[0]?.code || ""; |
| 30 | |
| 31 | const save = useMutation({ |
| 32 | mutationFn: () => { |
| 33 | const body: SaveTripInput = { destinationCode: selectedDestinationCode, city: city.trim(), startsOn, endsOn }; |
| 34 | return api<Trip>(trip ? `/api/v1/trips/${trip.id}` : "/api/v1/trips", { |
| 35 | method: trip ? "PUT" : "POST", |
| 36 | ...jsonBody(body) |
| 37 | }); |
| 38 | }, |
| 39 | onSuccess: async () => { |
| 40 | await queryClient.invalidateQueries(); |
| 41 | onClose(); |
| 42 | }, |
| 43 | onError: caught => setError(caught instanceof ApiError ? caught.message : "The trip could not be saved.") |
| 44 | }); |
| 45 | |
| 46 | function submit(event: FormEvent) { |
| 47 | event.preventDefault(); |
| 48 | setError(""); |
| 49 | save.mutate(); |
| 50 | } |
| 51 | |
| 52 | return ( |
| 53 | <div className="dialog-backdrop" role="presentation" onMouseDown={event => { |
| 54 | if (event.currentTarget === event.target) onClose(); |
| 55 | }}> |
| 56 | <section ref={dialogRef} className="small-dialog" role="dialog" aria-modal="true" aria-labelledby="trip-dialog-title"> |
| 57 | <header className="dialog-header"> |
| 58 | <div> |
| 59 | <span className="eyebrow">Food mission</span> |
| 60 | <h2 id="trip-dialog-title">{trip ? "Edit trip" : "Where are you going?"}</h2> |
| 61 | </div> |
| 62 | <button className="icon-button" type="button" onClick={onClose} aria-label="Close"><X size={20} /></button> |
| 63 | </header> |
| 64 | <form className="dialog-form" onSubmit={submit}> |
| 65 | <label> |
| 66 | <span>Destination</span> |
| 67 | <select data-initial-focus={!trip ? "true" : undefined} value={selectedDestinationCode} onChange={event => setDestinationCode(event.target.value)} disabled={Boolean(trip)} required> |
| 68 | {destinations.data?.map(item => <option key={item.code} value={item.code}>{item.name}</option>)} |
| 69 | </select> |
| 70 | {trip && <small>A new destination gets a new mission.</small>} |
| 71 | </label> |
| 72 | <label> |
| 73 | <span>City or route name</span> |
| 74 | <input data-initial-focus={trip ? "true" : undefined} value={city} onChange={event => setCity(event.target.value)} placeholder="Osaka" maxLength={100} required /> |
| 75 | </label> |
| 76 | <div className="form-grid form-grid-two"> |
| 77 | <label> |
| 78 | <span>Starts</span> |
| 79 | <input type="date" value={startsOn} onChange={event => setStartsOn(event.target.value)} required /> |
| 80 | </label> |
| 81 | <label> |
| 82 | <span>Ends</span> |
| 83 | <input type="date" value={endsOn} min={startsOn || todayInput()} onChange={event => setEndsOn(event.target.value)} required /> |
| 84 | </label> |
| 85 | </div> |
| 86 | {error && <p className="form-error" role="alert">{error}</p>} |
| 87 | <footer className="dialog-actions"> |
| 88 | <button className="button button-ghost" type="button" onClick={onClose}>Cancel</button> |
| 89 | <button className="button button-primary" type="submit" disabled={save.isPending}> |
| 90 | {save.isPending && <LoaderCircle className="spin" size={17} />} |
| 91 | {trip ? "Save trip" : "Create mission"} |
| 92 | </button> |
| 93 | </footer> |
| 94 | </form> |
| 95 | </section> |
| 96 | </div> |
| 97 | ); |
| 98 | } |
| 99 | |