From 012fa83f212027a6b92ff4c332a686ac9e58cb53 Mon Sep 17 00:00:00 2001 From: jakehobbs Date: Sat, 30 May 2026 17:01:45 -0700 Subject: [PATCH 01/12] feat(events): support scheduling public events in advance Add a richer event model so events can be created ahead of time: start/end time, IANA timezone, description, online flag, public flag, and a deduped Google Place location (new `locations` table). The event form expands into a scheduled-event flow when "Public event" is checked, with a Google Places address autocomplete and timezone-aware time fields. After creating a public event, the user lands on a confirmation page (name/date/time/location summary) offering "Take attendance now", "Edit event", "Create another event", and "Done" instead of being dropped on the attendance page. Adds a new Home hub listing today's events, points the post-login redirect and nav logo at /home, and serves a referrer-restricted Google Places key via the authed user-info endpoint. Co-Authored-By: Claude Opus 4.8 --- Makefile | 5 +- cli/cmd/db.go | 4 +- .../[id]/confirmation/event-confirmation.tsx | 116 +++ .../events/[id]/confirmation/page.tsx | 45 ++ .../src/app/(authed)/events/[id]/page.tsx | 7 +- .../src/app/(authed)/events/event-form.tsx | 723 ++++++++++++++---- .../src/app/(authed)/events/new/page.tsx | 2 +- .../(authed)/events/places-autocomplete.tsx | 198 +++++ .../src/app/(authed)/home/home-hub.tsx | 210 +++++ frontend-v2/src/app/(authed)/home/page.tsx | 10 + frontend-v2/src/app/(authed)/layout.tsx | 7 +- frontend-v2/src/app/authed-page-provider.tsx | 2 + frontend-v2/src/app/session.ts | 2 + frontend-v2/src/components/nav.tsx | 8 +- frontend-v2/src/components/ui/time-field.tsx | 95 +++ frontend-v2/src/lib/api.ts | 42 + frontend-v2/src/lib/timezone.ts | 143 ++++ ...0000_advance_events_and_locations.down.sql | 11 + ...120000_advance_events_and_locations.up.sql | 25 + pkg/shared/wipe.go | 2 + server/src/main.go | 6 +- server/src/model/event.go | 208 ++++- 22 files changed, 1709 insertions(+), 162 deletions(-) create mode 100644 frontend-v2/src/app/(authed)/events/[id]/confirmation/event-confirmation.tsx create mode 100644 frontend-v2/src/app/(authed)/events/[id]/confirmation/page.tsx create mode 100644 frontend-v2/src/app/(authed)/events/places-autocomplete.tsx create mode 100644 frontend-v2/src/app/(authed)/home/home-hub.tsx create mode 100644 frontend-v2/src/app/(authed)/home/page.tsx create mode 100644 frontend-v2/src/components/ui/time-field.tsx create mode 100644 frontend-v2/src/lib/timezone.ts create mode 100644 pkg/shared/db-migrations/20260530120000_advance_events_and_locations.down.sql create mode 100644 pkg/shared/db-migrations/20260530120000_advance_events_and_locations.up.sql diff --git a/Makefile b/Makefile index c0cea040..627f04f9 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,10 @@ run_all: run: cd server/src && go install # Install first so that we keep cached build objects around. - set -a && . server/debug.env && set +a && \ + set -a && \ + . server/debug.env && \ + { [ -f .env ] && . ./.env || true; } && \ + set +a && \ cd server/src && \ PORT=$(PORT) go run main.go diff --git a/cli/cmd/db.go b/cli/cmd/db.go index 7ccf3578..3798d9d0 100644 --- a/cli/cmd/db.go +++ b/cli/cmd/db.go @@ -103,7 +103,9 @@ INSERT INTO activists VALUES (`+ch+`, 1000, 'nnn', 'test2@gmail.com', '', 'United States', 'Supporter', 'Petition: no-more-bad-things', '`+currentDateString(1)+`'); -INSERT INTO events VALUES +INSERT INTO events + (id, name, date, event_type, survey_sent, suppress_survey, circle_id, chapter_id) + VALUES %s INSERT INTO event_attendance (activist_id, event_id) VALUES diff --git a/frontend-v2/src/app/(authed)/events/[id]/confirmation/event-confirmation.tsx b/frontend-v2/src/app/(authed)/events/[id]/confirmation/event-confirmation.tsx new file mode 100644 index 00000000..3b3640b7 --- /dev/null +++ b/frontend-v2/src/app/(authed)/events/[id]/confirmation/event-confirmation.tsx @@ -0,0 +1,116 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useQuery } from '@tanstack/react-query' +import { format, parseISO } from 'date-fns' +import { + CheckCircle2, + Users, + Pencil, + CalendarPlus, + Home, + Clock, + MapPin, + Globe, +} from 'lucide-react' +import { API_PATH, apiClient } from '@/lib/api' +import { Button } from '@/components/ui/button' +import { formatEventTimeRange } from '@/lib/timezone' + +export function EventConfirmation({ eventId }: { eventId: number }) { + const router = useRouter() + + // Hydrated by the server page (same query key as the event form), so this + // resolves immediately on first render. + const { data: event } = useQuery({ + queryKey: [API_PATH.EVENT_GET, String(eventId)], + queryFn: ({ signal }) => apiClient.getEvent(eventId, signal), + }) + + const timeRange = event + ? formatEventTimeRange( + event.event_date, + event.start_time ?? '', + event.end_time ?? '', + event.timezone ?? '', + ) + : '' + const locationLabel = + event?.location?.name || event?.location?.formatted_address + + return ( +
+
+ +
+

Event created

+

+ Your event is scheduled. Take attendance when the event happens. +

+
+
+ + {event && ( +
+

{event.event_name}

+
+ + {event.event_type} + + {format(parseISO(event.event_date), 'PPP')} + {timeRange && ( + + + {timeRange} + + )} + {event.is_online ? ( + + + Online + + ) : ( + locationLabel && ( + + + {locationLabel} + + ) + )} +
+
+ )} + +
+ + + + +
+
+ ) +} diff --git a/frontend-v2/src/app/(authed)/events/[id]/confirmation/page.tsx b/frontend-v2/src/app/(authed)/events/[id]/confirmation/page.tsx new file mode 100644 index 00000000..cb6533c4 --- /dev/null +++ b/frontend-v2/src/app/(authed)/events/[id]/confirmation/page.tsx @@ -0,0 +1,45 @@ +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from '@tanstack/react-query' +import { notFound } from 'next/navigation' +import { ContentWrapper } from '@/app/content-wrapper' +import { EventConfirmation } from './event-confirmation' +import { API_PATH, ApiClient } from '@/lib/api' +import { getCookies } from '@/lib/auth' +import { redirectForHttpError } from '@/lib/server-auth' + +// Shown right after a scheduled (public) event is created. Attendance happens +// later, at the event, so we confirm the event is on the schedule and offer the +// natural next steps instead of dropping the user on the attendance page. +export default async function EventConfirmationPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const eventId = parseInt(id) + if (Number.isNaN(eventId)) { + notFound() + } + + const apiClient = new ApiClient(await getCookies()) + const queryClient = new QueryClient() + + await redirectForHttpError(() => + // Intentionally use fetchQuery instead of prefetchQuery; see redirectForHttpError for details. + queryClient.fetchQuery({ + queryKey: [API_PATH.EVENT_GET, String(eventId)], + queryFn: ({ signal }) => apiClient.getEvent(eventId, signal), + }), + ) + + return ( + + + + + + ) +} diff --git a/frontend-v2/src/app/(authed)/events/[id]/page.tsx b/frontend-v2/src/app/(authed)/events/[id]/page.tsx index a34d9572..6d470e58 100644 --- a/frontend-v2/src/app/(authed)/events/[id]/page.tsx +++ b/frontend-v2/src/app/(authed)/events/[id]/page.tsx @@ -12,10 +12,13 @@ import { redirectForHttpError } from '@/lib/server-auth' export default async function EditEventPage({ params, + searchParams, }: { params: Promise<{ id: string }> + searchParams: Promise<{ edit?: string }> }) { const { id } = await params + const { edit } = await searchParams const eventId = parseInt(id) if (Number.isNaN(eventId)) { notFound() @@ -35,8 +38,8 @@ export default async function EditEventPage({ return ( -

Attendance

- +

Event

+
) diff --git a/frontend-v2/src/app/(authed)/events/event-form.tsx b/frontend-v2/src/app/(authed)/events/event-form.tsx index 8497c5a6..ff625be6 100644 --- a/frontend-v2/src/app/(authed)/events/event-form.tsx +++ b/frontend-v2/src/app/(authed)/events/event-form.tsx @@ -4,6 +4,7 @@ import { useRef, useState, useEffect, useMemo } from 'react' import { useForm, useStore } from '@tanstack/react-form' import { z } from 'zod' import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Checkbox } from '@/components/ui/checkbox' @@ -23,9 +24,16 @@ import { useAuthedPageContext } from '@/hooks/useAuthedPageContext' import { SF_BAY_CHAPTER_ID } from '@/lib/constants' import { AttendeeInputField } from './attendee-input-field' import { useActivistRegistry } from './useActivistRegistry' +import { PlacesAutocomplete } from './places-autocomplete' import { DatePicker } from '@/components/ui/date-picker' import { format, parseISO } from 'date-fns' -import { Save } from 'lucide-react' +import { Save, ChevronDown, ChevronUp } from 'lucide-react' +import { TimeField } from '@/components/ui/time-field' +import { + getBrowserTimezone, + getCommonTimezones, + getZoneAbbreviation, +} from '@/lib/timezone' const EVENT_TYPES = [ 'Action', @@ -66,27 +74,66 @@ const attendeeSchema = z.object({ ), }) -const formSchema = z.object({ - eventName: z.string().min(1, 'Event name is required'), - eventType: z.string().min(1, 'Event type is required'), - eventDate: z.string().min(1, 'Event date is required'), - suppressSurvey: z.boolean(), - attendees: z.array(attendeeSchema), -}) +const formSchema = z + .object({ + eventName: z.string().min(1, 'Event name is required'), + eventType: z.string().min(1, 'Event type is required'), + eventDate: z.string().min(1, 'Event date is required'), + suppressSurvey: z.boolean(), + attendees: z.array(attendeeSchema), + // Upcoming-event fields. Optional at the schema level; mode-specific + // requirements (e.g. attendance) are enforced in onSubmit. + isPublic: z.boolean(), + isOnline: z.boolean(), + description: z.string(), + startTime: z.string(), + endTime: z.string(), + timezone: z.string(), + googlePlaceId: z.string(), + locationName: z.string(), + formattedAddress: z.string(), + lat: z.number().optional(), + lng: z.number().optional(), + }) + .refine((v) => !(v.startTime && v.endTime) || v.endTime >= v.startTime, { + message: 'End time must be after start time', + path: ['endTime'], + }) + // Publicly listed events are scheduled in advance, so a start time is required. + .refine((v) => !v.isPublic || Boolean(v.startTime), { + message: 'Start time is required for public events', + path: ['startTime'], + }) + // In-person public events need a location; online ones don't. + .refine((v) => !v.isPublic || v.isOnline || Boolean(v.googlePlaceId), { + message: 'Location is required for in-person public events', + path: ['formattedAddress'], + }) type FormValues = z.infer +// 'event' is the unified create/edit flow — a quick attendance log by default, +// expanding into a scheduled public event when "Public event" is checked. +// 'connection' is coaching, which is genuinely different. +export type EventFormMode = 'event' | 'connection' + type EventFormProps = { - mode: 'event' | 'connection' + mode: EventFormMode + // When editing a saved event, start with the detail fields expanded rather + // than collapsed behind the summary bar. Used by the post-create confirmation + // page's "Edit event" link (?edit=1) so the user lands ready to fix details. + startExpanded?: boolean } -export const EventForm = ({ mode }: EventFormProps) => { +export const EventForm = ({ mode, startExpanded }: EventFormProps) => { const router = useRouter() const params = useParams() const queryClient = useQueryClient() - const { user } = useAuthedPageContext() + const { user, googlePlacesApiKey } = useAuthedPageContext() const eventId = params.id ? String(params.id) : undefined const isConnection = mode === 'connection' + const browserTz = useMemo(() => getBrowserTimezone(), []) + const timezones = useMemo(() => getCommonTimezones(), []) const inputRefs = useRef<(HTMLInputElement | null)[]>( Array(DEFAULT_FIELD_COUNT).fill(null), @@ -110,10 +157,43 @@ export const EventForm = ({ mode }: EventFormProps) => { enabled: !!eventId, }) + // The scheduled-event fields (time, location, description) are revealed by the + // "Public event" checkbox — see showUpcomingFields below. We also surface them + // when editing an event that already carries this data, so any pre-existing + // event stays fully editable even if its Public box is unchecked. + const editingHasUpcomingData = Boolean( + eventData && + (eventData.is_public || + eventData.is_online || + eventData.start_time || + eventData.location?.google_place_id || + eventData.description), + ) + + // When editing a saved event the detail fields (name, type, date, schedule, + // location, description) are usually already set, so they collapse behind a + // summary header to keep the attendee list near the top for taking + // attendance. New events start expanded since you're filling them out. + const [detailsExpanded, setDetailsExpanded] = useState( + startExpanded ?? !eventId, + ) + const saveEventMutation = useMutation({ mutationFn: isConnection ? apiClient.saveCoaching : apiClient.saveEvent, onSuccess: (result, variables) => { toast.success(`${isConnection ? 'Connection' : 'Event'} saved!`) + + // A brand-new scheduled (public) event is created in advance, so jumping + // straight to attendance is awkward. Route to a confirmation page with + // next-step choices instead, and skip the form-reset/routing below. + if (!eventId && variables.is_public) { + // Surface the new event in every list (home's "today", the events page) + // without a manual refresh. + queryClient.invalidateQueries({ queryKey: [API_PATH.EVENT_LIST] }) + router.push(`/events/${result.event_id}/confirmation`) + return + } + if (!eventId) { const target = isConnection ? `/coachings/${result.event_id}` @@ -121,6 +201,11 @@ export const EventForm = ({ mode }: EventFormProps) => { router.push(target) } + // Collapse the detail fields once saved, returning focus to the attendee + // list. (New events collapse anyway via the remount above, but this also + // covers editing a saved event with the details expanded.) + setDetailsExpanded(false) + // Reset the form's dirty state after successful save. // This prevents "unsaved changes" warning after successful save. @@ -132,6 +217,7 @@ export const EventForm = ({ mode }: EventFormProps) => { .map((a) => (a.name || '').trim()) .filter((n) => n !== '') + const current = form.state.values const newValues = { eventName: variables.event_name, eventType: variables.event_type, @@ -144,6 +230,17 @@ export const EventForm = ({ mode }: EventFormProps) => { .fill(null) .map(() => ({ name: '' })), ), + isPublic: current.isPublic, + isOnline: current.isOnline, + description: current.description, + startTime: current.startTime, + endTime: current.endTime, + timezone: current.timezone, + googlePlaceId: current.googlePlaceId, + locationName: current.locationName, + formattedAddress: current.formattedAddress, + lat: current.lat, + lng: current.lng, } // Use keepDefaultValues to work around TanStack Form bug: @@ -161,6 +258,12 @@ export const EventForm = ({ mode }: EventFormProps) => { queryClient.invalidateQueries({ queryKey: [API_PATH.EVENT_GET, eventId], }) + // Invalidate every event list (home's "today" list, the full events + // page) so a newly created/edited event shows without a manual refresh. + // The global 60s staleTime would otherwise serve cached data on return. + queryClient.invalidateQueries({ + queryKey: [API_PATH.EVENT_LIST], + }) }, onError: (error: Error) => { toast.error(error.message || 'Error saving event') @@ -186,8 +289,22 @@ export const EventForm = ({ mode }: EventFormProps) => { : Array(DEFAULT_FIELD_COUNT) .fill(null) .map(() => ({ name: '' })), + // Scheduled-event fields. Public defaults off, so a new event starts as the + // quick attendance form; checking "Public event" reveals the rest. Times + // come back from MySQL as "HH:MM:SS"; trim to the "HH:MM" the input expects. + isPublic: eventData?.is_public ?? false, + isOnline: eventData?.is_online ?? false, + description: eventData?.description ?? '', + startTime: (eventData?.start_time ?? '').slice(0, 5), + endTime: (eventData?.end_time ?? '').slice(0, 5), + timezone: eventData?.timezone || browserTz, + googlePlaceId: eventData?.location?.google_place_id ?? '', + locationName: eventData?.location?.name ?? '', + formattedAddress: eventData?.location?.formatted_address ?? '', + lat: eventData?.location?.lat ?? undefined, + lng: eventData?.location?.lng ?? undefined, } - }, [eventData, isConnection, user.ChapterID]) + }, [eventData, isConnection, user.ChapterID, mode, browserTz]) const form = useForm({ defaultValues: initialValues, @@ -203,7 +320,15 @@ export const EventForm = ({ mode }: EventFormProps) => { .map((a) => (a.name || '').trim()) .filter((n) => n !== '') - if (attendeeNames.length === 0) { + // Public events carry the richer scheduled-event payload (time, location, + // description); quick attendance entry and coaching stay the plain payload. + // Pre-existing events with this data keep it editable either way. + const includeUpcoming = + !isConnection && (value.isPublic || editingHasUpcomingData) + + // Attendees are required for quick attendance entry and coaching, but not + // for public events (those are usually filled in later). + if (!includeUpcoming && attendeeNames.length === 0) { toast.error('At least one attendee is required') return } @@ -237,6 +362,28 @@ export const EventForm = ({ mode }: EventFormProps) => { added_attendees: addedAttendees, deleted_attendees: deletedAttendees, suppress_survey: value.suppressSurvey, + // Only send scheduled-event fields when they're in play, keeping the + // plain attendance/connection save payload unchanged. + ...(includeUpcoming && { + is_public: value.isPublic, + is_online: value.isOnline, + description: value.description.trim(), + start_time: value.startTime, + end_time: value.endTime, + timezone: value.timezone, + // Online events (or no place picked) have no physical location. + ...(value.isOnline || !value.googlePlaceId + ? {} + : { + location: { + google_place_id: value.googlePlaceId, + name: value.locationName, + formatted_address: value.formattedAddress, + lat: value.lat, + lng: value.lng, + }, + }), + }), }) }, }) @@ -253,6 +400,25 @@ export const EventForm = ({ mode }: EventFormProps) => { const eventType = useStore(form.store, (state) => state.values.eventType) const eventName = useStore(form.store, (state) => state.values.eventName) const isDirty = useStore(form.store, (state) => state.isDirty) + const isOnline = useStore(form.store, (state) => state.values.isOnline) + const isPublic = useStore(form.store, (state) => state.values.isPublic) + const eventDate = useStore(form.store, (state) => state.values.eventDate) + const timezone = useStore(form.store, (state) => state.values.timezone) + const locationName = useStore( + form.store, + (state) => state.values.locationName, + ) + + // The "Public event" checkbox reveals the scheduled-event fields (time, + // timezone, location, description). Editing an event that already carries + // that data keeps it visible regardless. Never shown for coaching. + const showUpcomingFields = + !isConnection && (isPublic || editingHasUpcomingData) + // A brand-new public event must be saved before attendance can be recorded — + // attendance happens later, at the event. Everywhere else (quick attendance + // entry, coaching, and any saved event) the attendee fields show right away. + const isNewPublicEvent = !eventId && showUpcomingFields + const showAttendeeSection = !isNewPublicEvent // Predicts whether the server will send a survey by default. const shouldShowSuppressSurveyCheckbox = useMemo(() => { @@ -349,161 +515,422 @@ export const EventForm = ({ mode }: EventFormProps) => { }} className="flex flex-col gap-4" > - {/* Event/Connection Name Field */} - - {(field) => ( -
- - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - placeholder={`Enter ${isConnection ? 'connection' : 'event'} name`} - className={cn(field.state.meta.errors[0] && 'border-red-500')} - /> - {field.state.meta.errors[0] && ( -

- {field.state.meta.errors[0]?.message} -

- )} + {/* Summary toggle. Only when editing a saved event: the detail fields + collapse behind this bar so attendees sit near the top. The bar itself + is the toggle in both states (chevron flips), so there's no separate + collapse button. */} + {eventId && ( + )} - {/* Event Date Field */} - - {(field) => ( -
- -
-
- { - field.handleChange(date ? format(date, 'yyyy-MM-dd') : '') - }} - placeholder="Pick a date" + {/* Detail fields. Always shown for new events; collapsible when editing. */} + {(!eventId || detailsExpanded) && ( + <> + {/* Event/Connection Name Field */} + + {(field) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder={`Enter ${ + isConnection ? 'connection' : 'event' + } name`} className={cn(field.state.meta.errors[0] && 'border-red-500')} /> {field.state.meta.errors[0] && ( -

+

{field.state.meta.errors[0]?.message}

)}
- -
-
- )} - - - {/* Suppress Survey Checkbox */} - {shouldShowSuppressSurveyCheckbox && ( - - {(field) => ( -
- - field.handleChange(Boolean(checked)) - } - /> - {/* TODO: Consider renaming to "Send survey" with box checked by default. */} - -
+ )} +
+ + {/* Event Type Field - Only show for events, not connections */} + {!isConnection && ( + + {(field) => ( +
+ + + {field.state.meta.errors[0] && ( +

+ {field.state.meta.errors[0]?.message} +

+ )} +
+ )} +
)} - - )} - {/* Attendees/Coachees Section */} - - {(arrayField) => ( -
- -
- {arrayField.state.value.map((_, index) => { - const isFocused = index === activeInputIndex - return ( - - {(field) => ( - { - inputRefs.current[index] = el + {/* Event Date Field */} + + {(field) => ( +
+ +
+
+ { + field.handleChange( + date ? format(date, 'yyyy-MM-dd') : '', + ) + }} + placeholder="Pick a date" + className={cn( + field.state.meta.errors[0] && 'border-red-500', + )} + /> + {field.state.meta.errors[0] && ( +

+ {field.state.meta.errors[0]?.message} +

+ )} +
+ +
+
+ )} +
+ + {/* Public event toggle. Checking it reveals the scheduled-event fields + below. Hidden for coaching. */} + {!isConnection && ( + + {(field) => ( +
+ + field.handleChange(Boolean(checked)) + } + /> + +
+ )} +
+ )} + + {/* Scheduled-event fields: time, timezone, location, description */} + {showUpcomingFields && ( + <> + {/* Start / End Time */} +
+ + {(field) => ( +
+ + field.handleChange(v)} + onClear={() => field.handleChange('')} + hasError={Boolean(field.state.meta.errors[0])} + /> + {field.state.meta.errors[0] && ( +

+ {field.state.meta.errors[0]?.message} +

+ )} +
+ )} +
+ + {(field) => ( +
+ + field.handleChange(v)} + onClear={() => field.handleChange('')} + hasError={Boolean(field.state.meta.errors[0])} + /> + {field.state.meta.errors[0] && ( +

+ {field.state.meta.errors[0]?.message} +

+ )} +
+ )} +
+
+ + {/* Timezone */} + + {(field) => ( +
+ + +

+ Times are in {timezone.replace(/_/g, ' ')} + {getZoneAbbreviation(eventDate, timezone) + ? ` (${getZoneAbbreviation(eventDate, timezone)})` + : ''} + . +

+
+ )} +
+ + {/* Online checkbox */} + + {(field) => ( +
+ { + const online = Boolean(checked) + field.handleChange(online) + // Online events have no physical location: clear it. + if (online) { + form.setFieldValue('googlePlaceId', '') + form.setFieldValue('locationName', '') + form.setFieldValue('formattedAddress', '') + form.setFieldValue('lat', undefined) + form.setFieldValue('lng', undefined) + } + }} + /> + +
+ )} +
+ + {/* Location (Google Places autocomplete; no free-text) */} + {!isOnline && ( + + {(field) => ( +
+ + { + form.setFieldValue( + 'googlePlaceId', + place.google_place_id, + ) + form.setFieldValue( + 'locationName', + place.location_name, + ) + form.setFieldValue( + 'formattedAddress', + place.formatted_address, + ) + form.setFieldValue('lat', place.lat) + form.setFieldValue('lng', place.lng) }} - onFocus={setActiveInputIndex} - onAdvanceFocus={() => { - if (index < arrayField.state.value.length - 1) { - inputRefs.current[index + 1]?.focus() - } + onClear={() => { + form.setFieldValue('googlePlaceId', '') + form.setFieldValue('locationName', '') + form.setFieldValue('formattedAddress', '') + form.setFieldValue('lat', undefined) + form.setFieldValue('lng', undefined) }} - onChange={ensureMinimumEmptyFields} /> - )} - - ) - })} + {field.state.meta.errors[0] && ( +

+ {field.state.meta.errors[0]?.message} +

+ )} +
+ )} +
+ )} + + {/* Description */} + + {(field) => ( +
+ +