diff --git a/client/src/components/Planner/PlaceFormModal.form.test.ts b/client/src/components/Planner/PlaceFormModal.form.test.ts new file mode 100644 index 000000000..30d3f6518 --- /dev/null +++ b/client/src/components/Planner/PlaceFormModal.form.test.ts @@ -0,0 +1,158 @@ +// FE-PLANNER-PLFORMH-001 to FE-PLANNER-PLFORMH-010: the place form's openings and save +// payload, as the desktop dialog and the phone sheet both build them. +import { describe, expect, it } from 'vitest'; + +import type { Assignment, Place } from '../../types'; +import { + DEFAULT_FORM, + duplicateName, + openingAutoFilled, + openingDetailsSelection, + placeEditForm, + placeFormPayload, + prefillForm, +} from './PlaceFormModal.helpers'; + +const PLACE = { + id: 3, + name: 'Louvre', + description: 'Museum', + address: 'Rue de Rivoli', + lat: 48.86, + lng: 2.33, + category_id: 4, + place_time: '09:00', + end_time: '10:00', + notes: 'Bring water', + transport_mode: null, + website: 'https://louvre.fr', + google_place_id: null, + amap_poi_id: 'amap-1', + osm_id: 'W1', +} as unknown as Place; + +const ASSIGNMENT = { + id: 11, + notes: 'Day note', + place: { place_time: '14:00', end_time: '' }, +} as unknown as Assignment; + +describe('placeEditForm', () => { + it('FE-PLANNER-PLFORMH-001: reads the place, with its times off the assignment in context and its day note', () => { + expect(placeEditForm(PLACE, ASSIGNMENT)).toEqual({ + name: 'Louvre', + description: 'Museum', + address: 'Rue de Rivoli', + lat: '48.86', + lng: '2.33', + category_id: '4', + place_time: '14:00', + end_time: '', + notes: 'Bring water', + transport_mode: 'walking', + website: 'https://louvre.fr', + assignment_notes: 'Day note', + }); + }); + + it("FE-PLANNER-PLFORMH-002: without an assignment the times are the place's and no day note key exists", () => { + const form = placeEditForm(PLACE, null, { phone: '+33' }); + expect(form.place_time).toBe('09:00'); + expect(form.end_time).toBe('10:00'); + expect(form.phone).toBe('+33'); + expect('assignment_notes' in form).toBe(false); + }); + + it('FE-PLANNER-PLFORMH-003: empty coordinates and category stay empty strings', () => { + const bare = { ...PLACE, lat: null, lng: null, category_id: null, name: null } as unknown as Place; + const form = placeEditForm(bare, undefined); + expect([form.lat, form.lng, form.category_id, form.name]).toEqual(['', '', '', '']); + }); +}); + +describe('prefillForm', () => { + it("FE-PLANNER-PLFORMH-004: a blank form at the POI, with what it knew and the shell's own extras", () => { + const form = prefillForm({ lat: 1.5, lng: 2.5, name: 'Cafe', osm_id: 'N9' }, { category_id: '3' }); + expect(form).toEqual({ + ...DEFAULT_FORM, + lat: '1.5', + lng: '2.5', + name: 'Cafe', + address: '', + website: '', + phone: '', + osm_id: 'N9', + category_id: '3', + }); + }); +}); + +describe('openingDetailsSelection', () => { + it('FE-PLANNER-PLFORMH-005: the edited place first, then the POI, then nothing', () => { + expect(openingDetailsSelection(PLACE, { lat: 1, lng: 2 })).toEqual({ + placeId: 'amap-1', + lat: 48.86, + lng: 2.33, + name: 'Louvre', + }); + const unplaced = { ...PLACE, lat: null } as unknown as Place; + expect(openingDetailsSelection(unplaced, { lat: 1, lng: 2, name: 'Cafe' })).toEqual({ + placeId: undefined, + lat: 1, + lng: 2, + name: 'Cafe', + }); + expect(openingDetailsSelection(unplaced, null)).toBeNull(); + }); +}); + +describe('openingAutoFilled', () => { + it('FE-PLANNER-PLFORMH-006: only a POI opening owns fields, and only the ones it filled', () => { + const prefill = { lat: 1, lng: 2, name: 'Cafe', address: '', phone: '+1' }; + expect([...openingAutoFilled(null, prefill)].sort()).toEqual(['lat', 'lng', 'name', 'phone']); + expect(openingAutoFilled(PLACE, prefill).size).toBe(0); + expect(openingAutoFilled(null, null).size).toBe(0); + }); +}); + +describe('duplicateName', () => { + it('FE-PLANNER-PLFORMH-007: names the existing place, or the typed name when that one has none', () => { + const form = { ...DEFAULT_FORM, name: 'Louvre', lat: '48.86', lng: '2.33' }; + expect(duplicateName(form, [{ name: 'LOUVRE' }])).toBe('LOUVRE'); + expect(duplicateName(form, [{ name: null, lat: 48.86001, lng: 2.33 }])).toBe('Louvre'); + expect(duplicateName(form, [{ name: 'Orsay', lat: 40, lng: 2 }])).toBeNull(); + }); +}); + +describe('placeFormPayload', () => { + const form = { + ...DEFAULT_FORM, + name: 'Louvre', + lat: '48.86', + lng: '', + category_id: '', + assignment_notes: 'Day note', + }; + + it('FE-PLANNER-PLFORMH-008: numbers for the coordinates, null for an empty category, files only when chosen', () => { + const payload = placeFormPayload(form, [], null); + expect(payload.lat).toBe(48.86); + expect(payload.lng).toBeNull(); + expect(payload.category_id).toBeNull(); + expect(payload._pendingFiles).toBeUndefined(); + const pdf = new File(['x'], 'a.pdf'); + expect(placeFormPayload(form, [pdf], null)._pendingFiles).toEqual([pdf]); + }); + + it('FE-PLANNER-PLFORMH-009: the day note travels only with its assignment and only when it changed', () => { + expect('assignment_notes' in placeFormPayload(form, [], null)).toBe(false); + expect('assignment_notes' in placeFormPayload(form, [], { notes: 'Day note' })).toBe(false); + expect(placeFormPayload(form, [], { notes: 'Old note' }).assignment_notes).toBe('Day note'); + expect('assignment_notes' in placeFormPayload({ ...form, assignment_notes: '' }, [], { notes: null })).toBe(false); + }); + + it("FE-PLANNER-PLFORMH-010: a shell's extras are built from the parsed coordinates", () => { + const payload = placeFormPayload(form, [], null, (lat, lng) => ({ at: [lat, lng] })); + expect(payload.at).toEqual([48.86, null]); + }); +}); diff --git a/client/src/components/Planner/PlaceFormModal.helpers.ts b/client/src/components/Planner/PlaceFormModal.helpers.ts index 8e60c06d9..24e7c3a3f 100644 --- a/client/src/components/Planner/PlaceFormModal.helpers.ts +++ b/client/src/components/Planner/PlaceFormModal.helpers.ts @@ -1,4 +1,5 @@ -import type { Assignment, RoadtripStopType } from '@trek/shared' +import type { Assignment, Place, RoadtripStopType } from '@trek/shared' +import type { PlaceDetailsSelection } from './PlaceDetailsColumn' export interface PlaceFormData { name: string @@ -95,6 +96,102 @@ export const DEFAULT_FORM: PlaceFormData = { website: '', } +/** + * The place being edited, as the form edits it. Its times are stored per day-assignment, + * not on the pool place, so with an assignment in context (itinerary edit, or a + * single-assignment pool edit) they are read off its embedded place. `extra` is what one + * shell's form adds to the place's own fields, ahead of the day note. + */ +export function placeEditForm( + place: Place, + assignment: Assignment | null | undefined, + extra: Partial = {}, +): PlaceFormData { + const timeSource = assignment?.place ?? place + return { + name: place.name || '', + description: place.description || '', + address: place.address || '', + lat: place.lat != null ? String(place.lat) : '', + lng: place.lng != null ? String(place.lng) : '', + category_id: place.category_id != null ? String(place.category_id) : '', + place_time: timeSource.place_time || '', + end_time: timeSource.end_time || '', + notes: place.notes || '', + transport_mode: place.transport_mode || 'walking', + website: place.website || '', + ...extra, + // The day-specific note rides only with an assignment in context (#2163); + // otherwise the key stays absent so submit never sends a notes write. + ...(assignment ? { assignment_notes: assignment.notes || '' } : {}), + } +} + +/** A position the form opens on: a POI tapped on the map, or a right-click on it. */ +export interface PlacePrefill { + lat: number + lng: number + name?: string + address?: string + website?: string + phone?: string + osm_id?: string +} + +/** A blank form at a prefilled position, with what the POI already knew. */ +export function prefillForm(prefill: PlacePrefill, extra: Partial = {}): PlaceFormData { + return { + ...DEFAULT_FORM, + lat: String(prefill.lat), + lng: String(prefill.lng), + name: prefill.name || '', + address: prefill.address || '', + website: prefill.website || '', + phone: prefill.phone || '', + osm_id: prefill.osm_id, + ...extra, + } +} + +/** + * What the details column describes when the form opens: the place being edited, else + * whatever a map POI prefilled, else nothing yet. + */ +export function openingDetailsSelection( + place: Pick | null | undefined, + prefill: PlacePrefill | null | undefined, +): PlaceDetailsSelection | null { + if (place && place.lat != null && place.lng != null) { + return { + placeId: place.google_place_id || place.amap_poi_id || place.osm_id || undefined, + lat: Number(place.lat), + lng: Number(place.lng), + name: place.name || '', + } + } + if (prefill) { + return { placeId: prefill.osm_id || undefined, lat: prefill.lat, lng: prefill.lng, name: prefill.name || '' } + } + return null +} + +/** + * The fields a fresh opening owns. A blank form owns nothing yet. A POI tapped on the map + * or a right-click place arrives prefilled from a place, so those fields belong to it and + * a later search pick may clear them. An existing place being edited is the opposite: + * everything on that form came out of the database and none of it is a search result's + * to drop. + */ +export function openingAutoFilled(place: unknown, prefill: PlacePrefill | null | undefined): Set { + return new Set( + !place && prefill + ? (['name', 'address', 'lat', 'lng', 'website', 'phone', 'osm_id'] as ResultField[]).filter( + (field) => !!prefill[field as keyof PlacePrefill], + ) + : [], + ) +} + /** * The fields a maps result owns. Everything else in the form belongs to the * user and is never touched by picking a place. @@ -195,6 +292,50 @@ export function findDuplicatePlace( return null } +/** The name a likely duplicate (#1152) is announced with, or null when the trip has none. */ +export function duplicateName(form: PlaceFormData, places: Parameters[1]): string | null { + const dup = findDuplicatePlace(form, places) + return dup ? dup.name || form.name : null +} + +/** The form as the save takes it: coordinates as numbers, an empty category as null. */ +export interface PlaceFormPayload extends Omit { + lat: number | null + lng: number | null + category_id: string | null + /** Files chosen before the place existed: uploaded once the save has given it an id. */ + _pendingFiles?: File[] +} + +/** + * What a save sends. `extra` is what one shell adds after the category, from the + * coordinates being saved. The note of the day in context only travels when that + * assignment is in context AND the note actually changed (#2163): an untouched note must + * not produce a PUT, or a legacy note longer than the field would be re-sent on every + * unrelated save. + */ +export function placeFormPayload( + form: PlaceFormData, + pendingFiles: File[], + assignment: Pick | null | undefined, + extra?: (lat: number | null, lng: number | null) => E, +): PlaceFormPayload & E { + const lat = form.lat ? Number.parseFloat(form.lat) : null + const lng = form.lng ? Number.parseFloat(form.lng) : null + const payload = { + ...form, + lat, + lng, + category_id: form.category_id || null, + ...(extra ? extra(lat, lng) : {}), + _pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined, + } as PlaceFormPayload & E + if (!assignment || (form.assignment_notes ?? '') === (assignment.notes ?? '')) { + delete payload.assignment_notes + } + return payload +} + /** * A coordinate pair pasted into the latitude field, as "48.85, 2.35", "48.85;2.35" * or "48.85 2.35": the two halves, or null for anything else, which the field then diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index b5cf10706..4fb97c966 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -1,15 +1,13 @@ import { useState, useEffect, useRef, useMemo, useCallback, useId, type ReactNode } from 'react' import type { RoadtripStopType } from '@trek/shared' import NoteFormatToolbar from '../shared/NoteFormatToolbar' -import { mapsApi } from '../../api/client' -import { recordPlacePick } from '../../api/placeShadow' import { useAuthStore } from '../../store/authStore' import { useAddonStore } from '../../store/addonStore' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { useSettingsStore } from '../../store/settingsStore' import CollectionPicker from '../Collections/CollectionPicker' -import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn' +import PlaceDetailsColumn from './PlaceDetailsColumn' import { useToast } from '../shared/Toast' import { Tooltip } from '../shared/Tooltip' import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react' @@ -19,23 +17,22 @@ import { PlaceContactFields } from './PlaceContactFields' import { weekFromPeriods } from './placeHours' import type { PlaceOpeningHours } from '@trek/shared' import { - DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, isMapUrl, mergeResult, parseCoordinatePair, timeCollisions, - type PlaceFormData, type ResultField, + DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload, + prefillForm, timeCollisions, type PlaceFormData, } from './PlaceFormModal.helpers' +import { useNewCategory, usePlaceForm } from './usePlaceForm' +import { usePlaceSearch } from './usePlaceSearch' import { guessCategoryId } from './placeCategoryGuess' import { getNavigationTargets, openNavigationTarget } from './placeNavigation' import { NavigationMenu } from '../shared/NavigationMenu' -import { getApiErrorMessage } from '../../utils/apiError' -import { corePickRank, offersGoogleRetry, selectGoogleHoldsSlot } from '../../utils/placeSource' +import { offersGoogleRetry } from '../../utils/placeSource' import { safeHexColor } from '../../utils/safeColor' import { useLocationBias } from '../../hooks/useLocationBias' -import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions' import { BookingCostsSection } from './BookingCostsSection' import type { BookingExpenseRequest } from './BookingCostsSection.types' import type { Place, Category, Assignment, BudgetItem } from '../../types' import { NumericInput } from '../shared/NumericInput' import { formatDistance } from '../../utils/units' -import { PlacesSession } from '../../utils/placesSession' import ServiceStopSection from '../Roadtrip/ServiceStopSection' import { DEFAULT_SERVICE_KIND, serviceStopChoice, type ServiceStopMode } from '../Roadtrip/manualStop' import { STOP_KIND_BY_KEY, type StopKind } from '../Roadtrip/stopKinds' @@ -46,7 +43,6 @@ import { import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts' import { SoftPill, tintOf } from './planParts' import { WHITE_BUTTON } from './placeDialogParts' -import { usePlaceLanguage } from '../../hooks/usePlaceLanguage' // The submit payload mirrors the form, but lat/lng are parsed to numbers and // category_id is normalised, plus any files chosen before the place existed. @@ -92,24 +88,6 @@ interface PlaceFormModalProps { } -/** - * One row of the typed-ahead list, as the server sends it. - * - * `source`, `lat` and `lng` are optional because not every index fills them: - * Google answers with neither, and the mark falls back to the name the whole - * list carries. `place` is only on a plugin's row, which brings its whole place - * along because no details lookup knows a plugin id (#2221). - */ -type Suggestion = { - placeId: string - mainText: string - secondaryText: string - source?: string - lat?: number - lng?: number - place?: Record -} - /** The mark itself. Quiet on purpose: it answers a question, it does not advertise. */ function SourceBadge({ label }: { label: string | null }) { if (!label) return null @@ -127,36 +105,25 @@ function usePlaceFormModal(props: PlaceFormModalProps) { } = props // Hidden while the addon is off, because the kinds only mean anything to the road trip // rail: on an instance without it they would be six labels that change nothing. - const [form, setForm] = useState(DEFAULT_FORM) - const [mapsSearch, setMapsSearch] = useState('') - const [mapsResults, setMapsResults] = useState([]) - /** What answered the last full search. Only a fallback: a merged list carries the source per place. */ - const [searchSource, setSearchSource] = useState('') - // The list on screen answers "what is near the pin" rather than a typed query (#976). - const [nearbyList, setNearbyList] = useState(false) const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric' - /** - * What produced the list currently on screen, kept for the shadow log: the - * query as typed and the provider the envelope named. A ref rather than - * state because nothing renders from it and a pick must read the value that - * belonged to the list, not a value a re-render replaced. - */ - const searchMetaRef = useRef<{ query: string; source: string } | null>(null) - const acMetaRef = useRef<{ query: string; source: string } | null>(null) - const [isSearchingMaps, setIsSearchingMaps] = useState(false) - const [newCategoryName, setNewCategoryName] = useState('') - const [showNewCategory, setShowNewCategory] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const [duplicateWarning, setDuplicateWarning] = useState(null) + const toast = useToast() + const { t, locale } = useTranslation() + const { placesEnrichEnabled } = useAuthStore() + const can = useCanDo() + const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h' + const tripObj = useTripStore((s) => s.trip) + const canUploadFiles = can('file_upload', tripObj) + const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections')) + const isBudgetEnabled = useAddonStore((s) => s.isEnabled('budget')) + const deleteBudgetItem = useTripStore((s) => s.deleteBudgetItem) + const { + form, setForm, autoFilledRef, pendingFiles, addFilesFromInput, removeFile, handlePaste, isSaving, setIsSaving, + duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange: changeField, pickImage, + adoptDescription, warnIfDuplicate, + } = usePlaceForm({ t, toast, canUploadFiles }) // The index the picked place came from, as its row named it, shown as a pill // in the head band. Null for a place typed by hand or taken from the saved list. const [pickedSource, setPickedSource] = useState(null) - // What the detail column is describing. Null until the user picks a result. - const [detailsSelection, setDetailsSelection] = useState(null) - // Which fields the last picked search result wrote. Anything in here belongs - // to that place and goes when another is picked; anything outside it is the - // user's and survives. See mergeResult. - const autoFilledRef = useRef>(new Set()) // Whether the category was preselected from a search result rather than chosen, so // the next pick may replace it and a hand choice is never overwritten (#2282). const categoryGuessedRef = useRef(false) @@ -165,10 +132,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) { categoryGuessedRef.current = id != null return id != null ? String(id) : '' } - const [pendingFiles, setPendingFiles] = useState([]) - // The query the last full search found nothing for (#2472): the cue to add the - // place by hand instead of a silent empty list. - const [emptySearch, setEmptySearch] = useState(null) // The hours the details column looked up, as a week the contact block can take over (#2472). const [detailsHours, setDetailsHours] = useState(null) /** @@ -178,58 +141,23 @@ function usePlaceFormModal(props: PlaceFormModalProps) { */ const [serviceStopLeg, setServiceStopLeg] = useState('') const fileRef = useRef(null) - const [acSuggestions, setAcSuggestions] = useState([]) - // Which index answered the last keystroke, for the rows that do not say so - // themselves. Google and the OpenStreetMap fallback each answer from one - // place; the index path answers from two at once and marks every row. - const [acSource, setAcSource] = useState('') - const [acHighlight, setAcHighlight] = useState(-1) - const acDebounceRef = useRef | null>(null) - const acAbortRef = useRef(null) - // Counts the closings of this dialog, so an answer still on its way when it - // closed can tell that the opening it was asked for is over. A counter rather - // than an abort, because the full search takes no signal. - const searchEpochRef = useRef(0) - // Ties one search's keystrokes and its details lookup into a single Google - // billing session (see utils/placesSession). - const placesSessionRef = useRef(new PlacesSession()) - const toast = useToast() - const { t, locale } = useTranslation() - // Place names in the language the user picked for them, the app's otherwise (#1799). - const language = usePlaceLanguage() - const { placesEnrichEnabled } = useAuthStore() - const googleAnswers = useAuthStore(selectGoogleHoldsSlot) - const can = useCanDo() - const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h' - const tripObj = useTripStore((s) => s.trip) - const canUploadFiles = can('file_upload', tripObj) - const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections')) - const isBudgetEnabled = useAddonStore((s) => s.isEnabled('budget')) - const deleteBudgetItem = useTripStore((s) => s.deleteBudgetItem) // Set right before submit when the user clicked create/edit expense — the // place has to exist before an expense can point at it (see ReservationModal). const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null) + const newCategory = useNewCategory({ + variant: 'dialog', + create: category => onCategoryCreated?.(category), + onCreated: categoryId => setForm(prev => ({ ...prev, category_id: categoryId })), + t, + toast, + }) useEffect(() => { categoryGuessedRef.current = false + let next: PlaceFormData if (place) { - // Times are stored per day-assignment, not on the pool place. When an - // assignment is in context (itinerary edit, or a single-assignment pool - // edit) read the times off its embedded place; fall back to the place prop. const assignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null - const timeSource = assignment?.place ?? place - setForm({ - name: place.name || '', - description: place.description || '', - address: place.address || '', - lat: place.lat != null ? String(place.lat) : '', - lng: place.lng != null ? String(place.lng) : '', - category_id: place.category_id != null ? String(place.category_id) : '', - place_time: timeSource.place_time || '', - end_time: timeSource.end_time || '', - notes: place.notes || '', - transport_mode: place.transport_mode || 'walking', - website: place.website || '', + next = placeEditForm(place, assignment, { // Shown in the contact block now (#2472), so an edit carries what is there. phone: place.phone || '', email: place.email || '', @@ -239,20 +167,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) { // duration_minutes deliberately stays out: how long a stay takes belongs to the // rail's own dialog, and sending it from here would overwrite what was set there. stop_type: place.stop_type ?? null, - // The day-specific note rides only with an assignment in context (#2163); - // otherwise the key stays absent so submit never sends a notes write. - ...(assignment ? { assignment_notes: assignment.notes || '' } : {}), }) } else if (prefillCoords) { - setForm({ - ...DEFAULT_FORM, - lat: String(prefillCoords.lat), - lng: String(prefillCoords.lng), - name: prefillCoords.name || '', - address: prefillCoords.address || '', - website: prefillCoords.website || '', - phone: prefillCoords.phone || '', - osm_id: prefillCoords.osm_id, + next = prefillForm(prefillCoords, { category_id: guessedCategory(prefillCoords), stop_type: prefillCoords.stop_type ?? null, duration_minutes: prefillCoords.duration_minutes, @@ -269,29 +186,19 @@ function usePlaceFormModal(props: PlaceFormModalProps) { // nothing selected falls back to the constant. The dwell follows the kind, the // same way picking one by hand moves it. const kind = serviceStop.defaultKind ?? DEFAULT_SERVICE_KIND - setForm({ + next = { ...DEFAULT_FORM, stop_type: kind, duration_minutes: STOP_KIND_BY_KEY[kind].defaultMinutes, - }) + } } else { - setForm(DEFAULT_FORM) + next = DEFAULT_FORM } - // A fresh dialog owns nothing yet. The exception is a POI tapped on the map - // or a right-click place: those arrive prefilled from a place, so the same - // fields belong to it and a later search pick may clear them. An existing - // place being edited is the opposite — everything on that form came out of - // the database and none of it is a search result's to drop. - autoFilledRef.current = new Set( - !place && prefillCoords - ? (['name', 'address', 'lat', 'lng', 'website', 'phone', 'osm_id'] as ResultField[]).filter( - (field) => !!prefillCoords[field as keyof typeof prefillCoords], - ) - : [], - ) - setPendingFiles([]) + // The column follows whatever the dialog was opened with, not only a search + // pick: a POI tapped on the map and a right-click place arrive as + // prefillCoords, and editing an existing place arrives as `place`. + openForm(next, place, prefillCoords) setServiceStopLeg('') - setDuplicateWarning(null) setPickedSource(null) // A fresh dialog owns no intention either. The ref is armed by a click on the Costs // section and spent by the save that follows it; a save that never happened leaves it @@ -299,64 +206,72 @@ function usePlaceFormModal(props: PlaceFormModalProps) { // to. In service-stop mode that opening has no Costs section at all, so the editor // would arrive out of nowhere, on a petrol stop, filed as an activity. expenseIntentRef.current = null - // The column follows whatever the dialog was opened with, not only a search - // pick: a POI tapped on the map and a right-click place arrive as - // prefillCoords, and editing an existing place arrives as `place`. Without - // this the column kept showing the previous place's pictures and facts, - // because only handleSelectMapsResult ever set it. - if (place && place.lat != null && place.lng != null) { - setDetailsSelection({ - placeId: place.google_place_id || place.amap_poi_id || place.osm_id || undefined, - lat: Number(place.lat), - lng: Number(place.lng), - name: place.name || '', - }) - } else if (prefillCoords) { - setDetailsSelection({ - placeId: prefillCoords.osm_id || undefined, - lat: prefillCoords.lat, - lng: prefillCoords.lng, - name: prefillCoords.name || '', - }) - } else { - setDetailsSelection(null) - } // dayAssignments is a fresh array each render; read it at open-time only and // re-run on identity changes (place/assignmentId/open), not on every render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [place, prefillCoords, isOpen, assignmentId]) - // The planner keeps this dialog mounted while it is closed (the shell only - // stops drawing it), so everything the search block holds would greet the next - // opening: the last query, its list, the Google line offering that list's - // query again, suggestions still on their way. A pick from that list writes - // over the place being edited, whichever place that is by then. Reset when - // it closes, in an effect of its own: the one above also reruns while the - // dialog is open, when a right-click's reverse lookup fills prefillCoords, - // and must not wipe a search the user has started since. - useEffect(() => { - if (isOpen) return - searchEpochRef.current += 1 - if (acDebounceRef.current) clearTimeout(acDebounceRef.current) - acAbortRef.current?.abort() - placesSessionRef.current.end() - searchMetaRef.current = null - acMetaRef.current = null - setMapsSearch('') - setMapsResults([]) - setSearchSource('') - setAcSuggestions([]) - setAcSource('') - setAcHighlight(-1) - setIsSearchingMaps(false) - }, [isOpen]) - const places = useTripStore((s) => s.places) // Where the trip is happening — the hint that tells the search which of a // thousand identically named places is meant. Autocomplete wants a box, the - // search wants a point; useLocationBias derives both from the same places. - const { box: locationBias, point: locationBiasPoint } = useLocationBias() - const { autocomplete, sourceLabel } = usePlaceSuggestions() + // search wants a point; both are derived from the same places. + const { box: locationBias } = useLocationBias() + + /** + * The one point every pick flows through, so the detail column hangs here: a + * search row, a suggestion's place, or a place from the saved-place picker. + * `label` is the name the picked row's badge carried; a saved place names none. + */ + const applyPickedPlace = (result: Record, label: string | null) => { + setForm(prev => { + const merged = mergeResult(prev, result, autoFilledRef.current) + if (prev.category_id && !categoryGuessedRef.current) return merged + return { ...merged, category_id: guessedCategory(result) } + }) + setPickedSource(label) + // A new pick drops whatever hero image belonged to the previous place. + const lat = Number(result.lat) + const lng = Number(result.lng) + if (Number.isFinite(lat) && Number.isFinite(lng)) { + setDetailsSelection({ + placeId: (result.google_place_id || result.amap_poi_id || result.osm_id || undefined) as string | undefined, + lat, + lng, + name: (result.name as string) || '', + // Hand the record along: the server needs the same OSM tags, and + // looking them up again costs an Overpass round trip it can skip. + details: result, + }) + setForm(prev => ({ ...prev, image_url: undefined })) + } + } + + const search = usePlaceSearch({ + variant: 'dialog', + locationBias, + t, + toast, + // The planner keeps this dialog mounted while it is closed (the shell only + // stops drawing it), so the search block is reset when it closes, apart from + // the opening effect above: that one also reruns while the dialog is open, when + // a right-click's reverse lookup fills prefillCoords, and must not wipe a + // search the user has started since. + open: isOpen, + onPlace: applyPickedPlace, + onSuggestionName: name => setForm(prev => ({ ...prev, name })), + onMapLink: resolved => { + setForm(prev => ({ + ...prev, + name: resolved.name || prev.name, + address: resolved.address || prev.address, + lat: String(resolved.lat), + lng: String(resolved.lng), + google_ftid: resolved.google_ftid || prev.google_ftid, + })) + // The fields are the link's now, not the last picked result's. + setPickedSource(null) + }, + }) /** * What a stop on a drive might be a second copy of, said while the form is being filled. @@ -371,290 +286,14 @@ function usePlaceFormModal(props: PlaceFormModalProps) { return findDuplicatePlace(form, places, { byName: false, byOsmId: true })?.name ?? null }, [serviceStop, place, form, places]) - // Autocomplete fetch — aborts any in-flight request before starting a new one - const fetchSuggestions = useCallback(async (query: string) => { - if (query.length < 2 || isMapUrl(query)) { - setAcSuggestions([]) - setAcHighlight(-1) - return - } - acAbortRef.current?.abort() - const controller = new AbortController() - acAbortRef.current = controller - try { - const result = await autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current()) - acMetaRef.current = { query, source: result.source || 'unknown' } - setAcSuggestions(result.suggestions || []) - setAcSource(result.source || '') - setAcHighlight(-1) - } catch (err: unknown) { - if (err instanceof Error && err.name === 'AbortError') return - if (err instanceof Error && err.name === 'CanceledError') return // axios abort - console.error('Autocomplete failed:', err) - setAcSuggestions([]) - } - }, [autocomplete, language, locationBias]) - - // Debounce effect — only watches mapsSearch - useEffect(() => { - if (acDebounceRef.current) clearTimeout(acDebounceRef.current) - - const trimmed = mapsSearch.trim() - if (trimmed.length < 2 || isMapUrl(trimmed)) { - // A list still on its way belongs to a query that is gone. - acAbortRef.current?.abort() - setAcSuggestions([]) - setAcHighlight(-1) - placesSessionRef.current.end() - return - } - - acDebounceRef.current = setTimeout(() => fetchSuggestions(trimmed), 300) - - return () => { - if (acDebounceRef.current) clearTimeout(acDebounceRef.current) - } - }, [mapsSearch, fetchSuggestions]) - const handleChange = (field: string, value: string) => { - // Typed by hand, so the next pick must not clear it. - autoFilledRef.current.delete(field as ResultField) if (field === 'category_id') categoryGuessedRef.current = false - setForm(prev => ({ ...prev, [field]: value })) + changeField(field, value) } - const handleMapsSearch = async (provider?: 'google') => { - // The retry sends the query the list came from, not the field: the list - // stays on screen while the field is edited or cleared, and the line under - // it promises the same query. - const trimmed = provider ? (searchMetaRef.current?.query ?? '') : mapsSearch.trim() - if (!trimmed) return - const epoch = searchEpochRef.current - setIsSearchingMaps(true) - try { - // A pasted Google Maps or Amap link resolves server-side into a place - if (!provider && isMapUrl(trimmed)) { - const resolved = await mapsApi.resolveUrl(trimmed) - if (epoch !== searchEpochRef.current) return - if (resolved.lat && resolved.lng) { - setForm(prev => ({ - ...prev, - name: resolved.name || prev.name, - address: resolved.address || prev.address, - lat: String(resolved.lat), - lng: String(resolved.lng), - google_ftid: resolved.google_ftid || prev.google_ftid, - })) - setMapsResults([]) - setMapsSearch('') - // The fields are the link's now, not the last picked result's. - setPickedSource(null) - toast.success(t('places.urlResolved')) - return - } - } - const result = await mapsApi.search(trimmed, language, locationBiasPoint, provider) - if (epoch !== searchEpochRef.current) return - searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' } - setNearbyList(false) - setMapsResults(result.places || []) - setEmptySearch((result.places || []).length === 0 ? trimmed : null) - setSearchSource(result.source || '') - } catch (err: unknown) { - if (epoch !== searchEpochRef.current) return - toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) - } finally { - if (epoch === searchEpochRef.current) setIsSearchingMaps(false) - } - } - - // What is around the pin the form holds (#976): the list a search would show, - // nearest first. Not logged as a search pick, because no query was typed. + // What is around the pin the form holds (#976). const pin = formPin(form) - const handleNearby = async () => { - if (!pin) return - const epoch = searchEpochRef.current - setIsSearchingMaps(true) - setAcSuggestions([]) - try { - const result = await mapsApi.nearby(pin.lat, pin.lng, language) - if (epoch !== searchEpochRef.current) return - searchMetaRef.current = null - setNearbyList(true) - setMapsResults(result.places || []) - setSearchSource(result.source || '') - if (!result.places?.length) toast.info(t('places.nearbyNone')) - } catch (err: unknown) { - if (epoch !== searchEpochRef.current) return - toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) - } finally { - if (epoch === searchEpochRef.current) setIsSearchingMaps(false) - } - } - - /** - * `pick` is present only when the click came from a ranked list. The - * collection picker and the autocomplete detour reach this function with a - * place that was never ranked against a query, and a made-up rank would be - * worse than no row at all. - */ - const handleSelectMapsResult = (result, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => { - setForm(prev => { - const merged = mergeResult(prev, result, autoFilledRef.current) - if (prev.category_id && !categoryGuessedRef.current) return merged - return { ...merged, category_id: guessedCategory(result) } - }) - // The same name the row's badge carried. A suggestion's pick overrides it - // with its own row's (handleSelectSuggestion); a saved place names none. - setPickedSource(sourceLabel(result, pick?.mode === 'search' ? searchSource : '')) - // The one point every pick flows through, so the detail column hangs here. - // A new pick drops whatever hero image belonged to the previous place. - const lat = Number(result.lat) - const lng = Number(result.lng) - if (Number.isFinite(lat) && Number.isFinite(lng)) { - setDetailsSelection({ - placeId: result.google_place_id || result.amap_poi_id || result.osm_id || undefined, - lat, - lng, - name: result.name || '', - // Hand the record along: the server needs the same OSM tags, and - // looking them up again costs an Overpass round trip it can skip. - details: result, - }) - setForm(prev => ({ ...prev, image_url: undefined })) - if (pick) { - const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current - if (meta) { - recordPlacePick({ - query: meta.query, - lang: language, - // The bias the search actually ran under is a box around the trip's - // existing places; the corpus stores its centre, which is what an - // evaluation needs to bias its own index the same way. - biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined, - biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined, - source: `${pick.mode}:${meta.source}`, - liveRank: pick.rank, - liveCount: pick.count, - pickedName: result.name || '', - pickedLat: lat, - pickedLng: lng, - pickedPlaceId: result.google_place_id || result.amap_poi_id || result.osm_id || null, - }) - } - } - } - setMapsResults([]) - setMapsSearch('') - } - - const handleSelectSuggestion = async (suggestion: Suggestion) => { - // Read before the list is cleared: this is the rank the user saw. - const acPick = corePickRank(acSuggestions, suggestion) - acAbortRef.current?.abort() - setAcSuggestions([]) - setAcHighlight(-1) - const previousSearch = mapsSearch - const epoch = searchEpochRef.current - setMapsSearch('') - setForm(prev => ({ ...prev, name: suggestion.mainText })) - setIsSearchingMaps(true) - try { - // The details lookup is a fragile second hop — it can fail when the - // details kill-switch is off, when the OSM Overpass mirror is overloaded, - // or on any upstream error. Treat a missing/coordinate-less place as a - // miss and fall back to the reliable text-search path the search button - // uses (its results already carry coordinates), so dropdown items stay - // clickable instead of dead-ending on "Place search failed". (#1192) - let place: Record | null = suggestion.place ?? null - if (!place) { - try { - // Spends the session the suggestions opened, so Google bills the search - // once rather than per keystroke. - const result = await mapsApi.details(suggestion.placeId, language, placesSessionRef.current.peek()) - if (result.place && result.place.lat != null && result.place.lng != null) { - place = result.place - } - } catch (err) { - console.error('Failed to fetch place details:', err) - } - } - // Closed while the details were on their way: the pick belongs to an - // opening that is over, and the fallback search below is not worth a - // request nobody will see. - if (epoch !== searchEpochRef.current) return - if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) { - // The layer's rows carry no address; their second line is the name - // written on the building. Searching for "Tokio Hauptbahnhof, 東京駅" - // is not a question anybody asked, and its first answer would be - // whatever the index made of it — a different place, chosen silently. - // The suggestion already knows where it is, so use that. - place = { - name: suggestion.mainText, - address: '', - lat: suggestion.lat, - lng: suggestion.lng, - osm_id: suggestion.placeId, - source: 'openstreetmap', - } - } - if (!place) { - const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ') - const search = await mapsApi.search(query, language, locationBiasPoint) - if (epoch !== searchEpochRef.current) return - place = search.places?.[0] ?? null - } - if (place) { - handleSelectMapsResult(place, acPick && { mode: 'autocomplete', ...acPick }) - // Named after the row that was clicked, whatever the lookup behind it answered from. - setPickedSource(sourceLabel(suggestion, acSource)) - } else { - setMapsSearch(previousSearch) - toast.error(t('places.mapsSearchError')) - } - } catch (err) { - if (epoch !== searchEpochRef.current) return - console.error('Place suggestion lookup failed:', err) - setMapsSearch(previousSearch) - toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) - } finally { - // Closing already ended this session; ending it again here would cut off - // the one a new opening may have started meanwhile. - if (epoch === searchEpochRef.current) { - setIsSearchingMaps(false) - placesSessionRef.current.end() - } - } - } - - const handleSearchKeyDown = (e: React.KeyboardEvent) => { - if (acSuggestions.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault() - setAcHighlight(prev => (prev + 1) % acSuggestions.length) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setAcHighlight(prev => (prev <= 0 ? acSuggestions.length - 1 : prev - 1)) - } else if (e.key === 'Enter') { - e.preventDefault() - if (acHighlight >= 0) { - void handleSelectSuggestion(acSuggestions[acHighlight]) - } else { - setAcSuggestions([]) - void handleMapsSearch() - } - } else if (e.key === 'Escape') { - // Spent on the list: the dialog leaves an Escape that was already - // handled alone, so only the next one closes it. - e.preventDefault() - setAcSuggestions([]) - setAcHighlight(-1) - } - } else if (e.key === 'Enter') { - e.preventDefault() - void handleMapsSearch() - } - } + const handleNearby = () => search.searchNearby(pin) /** * The kind of stop, and with it how long that kind usually takes. @@ -665,48 +304,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) { */ const handleStopKind = useCallback((kind: RoadtripStopType) => { setForm(prev => ({ ...prev, stop_type: kind, duration_minutes: STOP_KIND_BY_KEY[kind].defaultMinutes })) - }, []) + }, [setForm]) const handleStopMinutes = useCallback((minutes: number) => { setForm(prev => ({ ...prev, duration_minutes: minutes })) - }, []) - - const handleCreateCategory = async () => { - if (!newCategoryName.trim()) return - try { - const cat = await onCategoryCreated?.({ name: newCategoryName, color: '#6366f1', icon: 'MapPin' }) // theme-lint-disable: the stored default of a new category, not a colour of this dialog - if (cat) setForm(prev => ({ ...prev, category_id: String(cat.id) })) - setNewCategoryName('') - setShowNewCategory(false) - } catch { - toast.error(t('places.categoryCreateError')) - } - } - - const handleFileAdd = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []) - setPendingFiles(prev => [...prev, ...files]) - e.target.value = '' - } - - const handleRemoveFile = (idx: number) => { - setPendingFiles(prev => prev.filter((_, i) => i !== idx)) - } - - // Paste support for files/images, anywhere in the dialog - const handlePaste = (e: React.ClipboardEvent) => { - if (!canUploadFiles) return - const items = e.clipboardData?.items - if (!items) return - for (const item of Array.from(items)) { - if (item.type.startsWith('image/') || item.type === 'application/pdf') { - e.preventDefault() - const file = item.getAsFile() - if (file) setPendingFiles(prev => [...prev, file]) - return - } - } - } + }, [setForm]) const hasTimeError = !!place && endsBeforeStart(form.place_time, form.end_time) @@ -729,27 +331,16 @@ function usePlaceFormModal(props: PlaceFormModalProps) { // save on it, only noted it. So the note is shown beside the name while the form is // being filled (see serviceStopDuplicate), which is the earlier word anyway, and the // press that saves is the press that saves. - if (!place && !serviceStop && !duplicateWarning) { - const dup = findDuplicatePlace(form, places) - if (dup) { - const dupName = dup.name || form.name - setDuplicateWarning(dupName) - toast.warning(t('places.duplicateExists', { name: dupName })) - // Nothing was saved, so an expense intent from a previous click is stale, and - // the next plain Save would otherwise open a Costs editor out of nowhere. - expenseIntentRef.current = null - return - } + if (!place && !serviceStop && !duplicateWarning && warnIfDuplicate(places)) { + // Nothing was saved, so an expense intent from a previous click is stale, and + // the next plain Save would otherwise open a Costs editor out of nowhere. + expenseIntentRef.current = null + return } setIsSaving(true) try { - const lat = form.lat ? Number.parseFloat(form.lat) : null - const lng = form.lng ? Number.parseFloat(form.lng) : null - const payload = { - ...form, - lat, - lng, - category_id: form.category_id || null, + const ctxAssignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null + const payload = placeFormPayload(form, pendingFiles, ctxAssignment, (lat, lng) => ({ // An explicit null is how a stop stops being a fuel stop; the service reads it // that way rather than as "leave alone", which is what a missing key means. stop_type: form.stop_type || null, @@ -765,16 +356,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { ...(serviceStop ? { _serviceStop: serviceStopChoice(serviceStop, lat, lng, serviceStopLeg).placement } : {}), - _pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined, - } - // #2163: the per-assignment note only travels when an assignment is in - // context AND the value actually changed — an untouched note must not - // produce a PUT (a legacy note longer than the textarea would otherwise - // be re-sent on every unrelated save). - const ctxAssignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null - if (!ctxAssignment || (form.assignment_notes ?? '') === (ctxAssignment.notes ?? '')) { - delete payload.assignment_notes - } + })) const saved = await onSave(payload) // Open the Costs editor for the saved place when the user asked to // create/edit its linked expense — gated on an id, so a create that the @@ -815,49 +397,50 @@ function usePlaceFormModal(props: PlaceFormModalProps) { isMobile, collectionsEnabled, form, - setForm, - mapsSearch, - setMapsSearch, - mapsResults, - isSearchingMaps, - newCategoryName, - setNewCategoryName, - showNewCategory, - setShowNewCategory, + mapsSearch: search.query, + setMapsSearch: search.setQuery, + mapsResults: search.results, + isSearchingMaps: search.searching, + newCategoryName: newCategory.name, + setNewCategoryName: newCategory.setName, + showNewCategory: newCategory.open, + setShowNewCategory: newCategory.setOpen, isSaving, pendingFiles, - emptySearch, setEmptySearch, + emptySearch: search.emptySearch, setEmptySearch: search.setEmptySearch, detailsHours, setDetailsHours, fileRef, - acSuggestions, - setAcSuggestions, - acSource, - searchSource, - acHighlight, - setAcHighlight, + acSuggestions: search.suggestions, + setAcSuggestions: search.setSuggestions, + acSource: search.acSource, + searchSource: search.searchSource, + acHighlight: search.highlight, + setAcHighlight: search.setHighlight, t, - language, + language: search.language, locale, timeFormat, - googleAnswers, + googleAnswers: search.googleAnswers, placesEnrichEnabled, canUploadFiles, locationBias, - fetchSuggestions, - sourceLabel, + fetchSuggestions: search.fetchSuggestions, + sourceLabel: search.sourceLabel, handleChange, - handleMapsSearch, + handleMapsSearch: search.runSearch, handleNearby, pin, - nearbyList, + nearbyList: search.nearbyList, distanceUnit, - handleSelectMapsResult, - handleSelectSuggestion, - handleSearchKeyDown, - handleCreateCategory, - handleFileAdd, - handleRemoveFile, + handleSelectMapsResult: search.pickPlace, + handleSelectSuggestion: search.selectSuggestion, + handleSearchKeyDown: search.handleKeyDown, + handleCreateCategory: newCategory.submit, + handleFileAdd: addFilesFromInput, + handleRemoveFile: removeFile, handlePaste, + pickImage, + adoptDescription, hasTimeError, handleSubmit, duplicateWarning, @@ -902,7 +485,7 @@ type Translate = (key: string, params?: Record) => stri export default function PlaceFormModal(props: PlaceFormModalProps) { const S = usePlaceFormModal(props) const { - isOpen, onClose, place, prefillCoords, categories, isMobile, collectionsEnabled, form, setForm, + isOpen, onClose, place, prefillCoords, categories, isMobile, collectionsEnabled, form, mapsSearch, setMapsSearch, mapsResults, isSearchingMaps, acSuggestions, setAcSuggestions, acSource, searchSource, acHighlight, setAcHighlight, t, language, googleAnswers, placesEnrichEnabled, canUploadFiles, fetchSuggestions, sourceLabel, handleChange, handleMapsSearch, handleSelectMapsResult, @@ -1021,8 +604,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { setForm(prev => ({ ...prev, image_url: url ?? undefined }))} - onAdoptDescription={(text) => setForm(prev => ({ ...prev, description: text }))} + onPickImage={S.pickImage} + onAdoptDescription={S.adoptDescription} onHours={(hours) => S.setDetailsHours(weekFromPeriods(hours.periods))} hasDescription={!!form.description.trim()} language={language} diff --git a/client/src/components/Planner/usePlaceForm.test.ts b/client/src/components/Planner/usePlaceForm.test.ts new file mode 100644 index 000000000..cf2e8e923 --- /dev/null +++ b/client/src/components/Planner/usePlaceForm.test.ts @@ -0,0 +1,244 @@ +// FE-PLANNER-PLFORM-001 to FE-PLANNER-PLFORM-014: the place form state behind the desktop +// dialog and the phone sheet, and the inline new category of both ('dialog' and 'sheet'). +import { act, renderHook } from '@testing-library/react'; +import type React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Category, Place } from '../../types'; +import { DEFAULT_FORM } from './PlaceFormModal.helpers'; +import { useNewCategory, usePlaceForm, type NewCategoryOptions } from './usePlaceForm'; + +const t = (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key; + +const makeToast = () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }); + +let toast: ReturnType; + +beforeEach(() => { + toast = makeToast(); +}); + +function setup(canUploadFiles = true) { + return renderHook(() => usePlaceForm({ t, toast, canUploadFiles })); +} + +const PLACE = { + id: 5, + name: 'Louvre', + lat: 48.86, + lng: 2.33, + google_place_id: 'g-louvre', + amap_poi_id: null, + osm_id: null, +} as unknown as Place; + +function file(name: string, type: string) { + return new File(['x'], name, { type }); +} + +function pasteEvent(items: { type: string; file: File | null }[]) { + const preventDefault = vi.fn(); + const event = { + preventDefault, + clipboardData: { items: items.map((item) => ({ type: item.type, getAsFile: () => item.file })) }, + } as unknown as React.ClipboardEvent; + return { event, preventDefault }; +} + +describe('usePlaceForm', () => { + it('FE-PLANNER-PLFORM-001: starts on the blank form with nothing attached, warned or described', () => { + const { result } = setup(); + expect(result.current.form).toEqual(DEFAULT_FORM); + expect(result.current.pendingFiles).toEqual([]); + expect(result.current.duplicateWarning).toBeNull(); + expect(result.current.detailsSelection).toBeNull(); + expect(result.current.isSaving).toBe(false); + expect(result.current.autoFilledRef.current.size).toBe(0); + }); + + it('FE-PLANNER-PLFORM-002: opening on a place being edited owns no field and describes that place', () => { + const { result } = setup(); + const next = { ...DEFAULT_FORM, name: 'Louvre' }; + act(() => result.current.openForm(next, PLACE, null)); + expect(result.current.form).toBe(next); + expect(result.current.autoFilledRef.current.size).toBe(0); + expect(result.current.detailsSelection).toEqual({ placeId: 'g-louvre', lat: 48.86, lng: 2.33, name: 'Louvre' }); + }); + + it('FE-PLANNER-PLFORM-003: opening on a map POI owns the fields it filled and describes the POI', () => { + const { result } = setup(); + const prefill = { lat: 1, lng: 2, name: 'Cafe', website: 'https://cafe.example', osm_id: 'N9' }; + act(() => result.current.openForm(DEFAULT_FORM, null, prefill)); + expect([...result.current.autoFilledRef.current].sort()).toEqual(['lat', 'lng', 'name', 'osm_id', 'website']); + expect(result.current.detailsSelection).toEqual({ placeId: 'N9', lat: 1, lng: 2, name: 'Cafe' }); + }); + + it('FE-PLANNER-PLFORM-004: a fresh opening drops the files and the warning the last one left', () => { + const { result } = setup(); + act(() => result.current.addFiles([file('a.pdf', 'application/pdf')])); + act(() => result.current.handleChange('name', 'Louvre')); + act(() => { + result.current.warnIfDuplicate([{ name: 'louvre' }]); + }); + expect(result.current.duplicateWarning).toBe('louvre'); + act(() => result.current.openForm(DEFAULT_FORM, null, null)); + expect(result.current.pendingFiles).toEqual([]); + expect(result.current.duplicateWarning).toBeNull(); + expect(result.current.detailsSelection).toBeNull(); + }); + + it('FE-PLANNER-PLFORM-005: a field typed by hand is no longer the last pick to clear', () => { + const { result } = setup(); + act(() => result.current.openForm(DEFAULT_FORM, null, { lat: 1, lng: 2, name: 'Cafe' })); + act(() => result.current.handleChange('name', 'My cafe')); + expect(result.current.form.name).toBe('My cafe'); + expect(result.current.autoFilledRef.current.has('name')).toBe(false); + expect(result.current.autoFilledRef.current.has('lat')).toBe(true); + }); + + it('FE-PLANNER-PLFORM-006: files are added from the input, which is cleared, and removed by index', () => { + const { result } = setup(); + const a = file('a.pdf', 'application/pdf'); + const b = file('b.png', 'image/png'); + const target = { files: [a, b], value: 'C:\\fakepath\\a.pdf' }; + act(() => result.current.addFilesFromInput({ target } as unknown as React.ChangeEvent)); + expect(result.current.pendingFiles).toEqual([a, b]); + expect(target.value).toBe(''); + act(() => result.current.removeFile(0)); + expect(result.current.pendingFiles).toEqual([b]); + }); + + it('FE-PLANNER-PLFORM-007: a pasted picture or PDF becomes an attachment, the first one only', () => { + const { result } = setup(); + const img = file('shot.png', 'image/png'); + const { event, preventDefault } = pasteEvent([ + { type: 'text/plain', file: null }, + { type: 'image/png', file: img }, + { type: 'application/pdf', file: file('x.pdf', 'application/pdf') }, + ]); + act(() => result.current.handlePaste(event)); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(result.current.pendingFiles).toEqual([img]); + }); + + it('FE-PLANNER-PLFORM-008: a member who may not upload pastes text as text', () => { + const { result } = setup(false); + const { event, preventDefault } = pasteEvent([{ type: 'image/png', file: file('shot.png', 'image/png') }]); + act(() => result.current.handlePaste(event)); + expect(preventDefault).not.toHaveBeenCalled(); + expect(result.current.pendingFiles).toEqual([]); + }); + + it('FE-PLANNER-PLFORM-009: the details column picks the image and hands over its description', () => { + const { result } = setup(); + act(() => result.current.pickImage('https://img.example/1.jpg')); + expect(result.current.form.image_url).toBe('https://img.example/1.jpg'); + act(() => result.current.pickImage(null)); + expect(result.current.form.image_url).toBeUndefined(); + act(() => result.current.adoptDescription('A museum')); + expect(result.current.form.description).toBe('A museum'); + }); + + it('FE-PLANNER-PLFORM-010: a likely duplicate warns once by name; none lets the save go on', () => { + const { result } = setup(); + act(() => result.current.handleChange('name', 'Louvre')); + let warned = true; + act(() => { + warned = result.current.warnIfDuplicate([{ name: 'Orsay' }]); + }); + expect(warned).toBe(false); + expect(toast.warning).not.toHaveBeenCalled(); + + act(() => { + warned = result.current.warnIfDuplicate([{ name: 'Louvre Museum' }, { name: ' louvre ' }]); + }); + expect(warned).toBe(true); + expect(result.current.duplicateWarning).toBe(' louvre '); + expect(toast.warning).toHaveBeenCalledWith('places.duplicateExists:{"name":" louvre "}'); + }); +}); + +function category(id: number): Category { + return { id, name: 'Food', color: '#6366f1', icon: 'MapPin' } as unknown as Category; +} + +describe('useNewCategory', () => { + function setupCategory(options: Pick) { + const onCreated = vi.fn(); + const hook = renderHook(() => useNewCategory({ ...options, onCreated, t, toast } as NewCategoryOptions)); + return { ...hook, onCreated }; + } + + it('FE-PLANNER-PLFORM-011: a blank name creates nothing', async () => { + const create = vi.fn(async () => category(1)); + const { result } = setupCategory({ variant: 'sheet', create }); + act(() => result.current.setName(' ')); + await act(async () => { + await result.current.submit(); + }); + expect(create).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLFORM-012: the desktop sends the name as typed, picks the new category and closes', async () => { + const create = vi.fn(async () => category(7)); + const { result, onCreated } = setupCategory({ variant: 'dialog', create }); + act(() => { + result.current.setOpen(true); + result.current.setName(' Food '); + }); + await act(async () => { + await result.current.submit(); + }); + expect(create).toHaveBeenCalledWith({ name: ' Food ', color: '#6366f1', icon: 'MapPin' }); + expect(onCreated).toHaveBeenCalledWith('7'); + expect(result.current.name).toBe(''); + expect(result.current.open).toBe(false); + }); + + it('FE-PLANNER-PLFORM-013: a desktop create that hands nothing back picks nothing but still closes', async () => { + const create = vi.fn(() => undefined); + const { result, onCreated } = setupCategory({ variant: 'dialog', create }); + act(() => { + result.current.setOpen(true); + result.current.setName('Food'); + }); + await act(async () => { + await result.current.submit(); + }); + expect(onCreated).not.toHaveBeenCalled(); + expect(result.current.open).toBe(false); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLFORM-014: the phone trims the name, ignores a second tap while saving and toasts a failure', async () => { + let finish: (c: Category) => void = () => {}; + const create = vi.fn(() => new Promise((resolve) => (finish = resolve))); + const { result, onCreated } = setupCategory({ variant: 'sheet', create }); + act(() => result.current.setName(' Food ')); + let first: Promise = Promise.resolve(); + act(() => { + first = result.current.submit(); + }); + expect(result.current.saving).toBe(true); + await act(async () => { + await result.current.submit(); + }); + expect(create).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledWith({ name: 'Food', color: '#6366f1', icon: 'MapPin' }); + await act(async () => { + finish(category(3)); + await first; + }); + expect(onCreated).toHaveBeenCalledWith('3'); + expect(result.current.saving).toBe(false); + + create.mockImplementation(() => Promise.reject(new Error('nope'))); + act(() => result.current.setName('Bar')); + await act(async () => { + await result.current.submit(); + }); + expect(toast.error).toHaveBeenCalledWith('places.categoryCreateError'); + expect(result.current.name).toBe('Bar'); + }); +}); diff --git a/client/src/components/Planner/usePlaceForm.ts b/client/src/components/Planner/usePlaceForm.ts new file mode 100644 index 000000000..20e8c8b1d --- /dev/null +++ b/client/src/components/Planner/usePlaceForm.ts @@ -0,0 +1,184 @@ +import type React from 'react'; +import { useRef, useState } from 'react'; + +import type { useTranslation } from '../../i18n'; +import type { Category } from '../../types'; +import type { useToast } from '../shared/Toast'; +import type { PlaceDetailsSelection } from './PlaceDetailsColumn'; +import { + DEFAULT_FORM, + duplicateName, + openingAutoFilled, + openingDetailsSelection, + type PlaceFormData, + type PlacePrefill, + type ResultField, +} from './PlaceFormModal.helpers'; + +type Translate = ReturnType['t']; +type Toast = ReturnType; + +export interface PlaceFormOptions { + t: Translate; + toast: Toast; + /** Files and pictures pasted into the form become attachments only for a member who may upload. */ + canUploadFiles: boolean; +} + +/** + * The place add/edit form's own state: the one logic path behind the desktop dialog and + * the phone sheet, which render their own markup over it and keep their own openings, + * search and save flow. It holds the fields, which of them the last picked search result + * wrote (see mergeResult), the files chosen before the place exists, what the details + * column describes, and the duplicate warning a first save of a new place may raise. + */ +export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) { + const [form, setForm] = useState(DEFAULT_FORM); + // Which fields the last picked search result wrote. Anything in here belongs + // to that place and goes when another is picked; anything outside it is the + // user's and survives. See mergeResult. + const autoFilledRef = useRef>(new Set()); + const [pendingFiles, setPendingFiles] = useState([]); + const [isSaving, setIsSaving] = useState(false); + const [duplicateWarning, setDuplicateWarning] = useState(null); + // What the details column is describing. Null until there is a place to describe. + const [detailsSelection, setDetailsSelection] = useState(null); + + /** + * A fresh opening, on the form the caller built for it: the fields that opening owns, + * what the details column follows (otherwise it kept showing the previous place's + * pictures and facts), no files and no warning left from the last one. + */ + const openForm = ( + next: PlaceFormData, + place: Parameters[0], + prefill: PlacePrefill | null | undefined + ) => { + setForm(next); + autoFilledRef.current = openingAutoFilled(place, prefill); + setDetailsSelection(openingDetailsSelection(place, prefill)); + setPendingFiles([]); + setDuplicateWarning(null); + }; + + const handleChange = (field: string, value: string) => { + // Typed by hand, so the next pick must not clear it. + autoFilledRef.current.delete(field as ResultField); + setForm((prev) => ({ ...prev, [field]: value })); + }; + + const addFiles = (files: File[]) => setPendingFiles((prev) => [...prev, ...files]); + + /** The file input's pick; the input is cleared so the same file can be chosen again. */ + const addFilesFromInput = (e: React.ChangeEvent) => { + addFiles(Array.from(e.target.files || [])); + e.target.value = ''; + }; + + const removeFile = (idx: number) => setPendingFiles((prev) => prev.filter((_, i) => i !== idx)); + + // Clipboard images/PDFs from any focused field become pending attachments. + const handlePaste = (e: React.ClipboardEvent) => { + if (!canUploadFiles) return; + for (const item of Array.from(e.clipboardData?.items || [])) { + if (item.type.startsWith('image/') || item.type === 'application/pdf') { + e.preventDefault(); + const file = item.getAsFile(); + if (file) setPendingFiles((prev) => [...prev, file]); + return; + } + } + }; + + /** A hero image picked in the details column, or none. */ + const pickImage = (url: string | null) => setForm((prev) => ({ ...prev, image_url: url ?? undefined })); + + /** The details column's description, taken over into the form. */ + const adoptDescription = (text: string) => setForm((prev) => ({ ...prev, description: text })); + + /** + * #1152: the first save of a new place warns when the trip seems to have it already; + * a second press with the warning showing is the explicit "add anyway". True when it + * warned, and the save stops there. + */ + const warnIfDuplicate = (places: Parameters[1]) => { + const name = duplicateName(form, places); + if (!name) return false; + setDuplicateWarning(name); + toast.warning(t('places.duplicateExists', { name })); + return true; + }; + + return { + form, + setForm, + autoFilledRef, + pendingFiles, + addFiles, + addFilesFromInput, + removeFile, + handlePaste, + isSaving, + setIsSaving, + duplicateWarning, + detailsSelection, + setDetailsSelection, + openForm, + handleChange, + pickImage, + adoptDescription, + warnIfDuplicate, + }; +} + +const NEW_CATEGORY_COLOR = '#6366f1'; // theme-lint-disable: a new category's stored default, not a colour of the form + +/** What a new category is created with, besides its name. */ +type NewCategory = { name: string; color: string; icon: string }; + +export type NewCategoryOptions = { + t: Translate; + toast: Toast; + /** The new category's id, once it exists: it is picked straight away. */ + onCreated: (categoryId: string) => void; +} & ( + | { + /** Desktop: the name goes as typed, and a create that hands nothing back picks nothing. */ + variant: 'dialog'; + create: (category: NewCategory) => Promise | undefined; + } + | { + /** Phone: the name goes trimmed, and a second create waits until the first is done. */ + variant: 'sheet'; + create: (category: NewCategory) => Promise; + } +); + +/** + * The inline "new category" of the place form, for both shells: a name typed where the + * category pills are, created with the default colour and icon and picked at once. + */ +export function useNewCategory({ t, toast, onCreated, variant, create }: NewCategoryOptions) { + const sheet = variant === 'sheet'; + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [saving, setSaving] = useState(false); + + const submit = async () => { + if (!name.trim() || (sheet && saving)) return; + if (sheet) setSaving(true); + try { + const cat = await create({ name: sheet ? name.trim() : name, color: NEW_CATEGORY_COLOR, icon: 'MapPin' }); + // The phone's create always hands one back; reading it unguarded fails into the catch as before. + if (sheet || cat) onCreated(String((cat as Category).id)); + setName(''); + setOpen(false); + } catch { + toast.error(t('places.categoryCreateError')); + } finally { + if (sheet) setSaving(false); + } + }; + + return { open, setOpen, name, setName, saving, submit }; +} diff --git a/client/src/components/Planner/usePlaceSearch.test.ts b/client/src/components/Planner/usePlaceSearch.test.ts new file mode 100644 index 000000000..fab335df0 --- /dev/null +++ b/client/src/components/Planner/usePlaceSearch.test.ts @@ -0,0 +1,453 @@ +// FE-PLANNER-PLSEARCH-001 to FE-PLANNER-PLSEARCH-022: the maps search behind the desktop +// place dialog (variant 'dialog') and the phone sheet's search row ('sheet'). +import { act, renderHook, waitFor } from '@testing-library/react'; +import type React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resetAllStores } from '../../../tests/helpers/store'; +import { mapsApi } from '../../api/client'; +import { recordPlacePick } from '../../api/placeShadow'; +import { usePlaceSearch, type PlaceSearchOptions } from './usePlaceSearch'; + +vi.mock('../../api/placeShadow', () => ({ recordPlacePick: vi.fn() })); + +type SearchResult = Awaited>; +type AutocompleteResult = Awaited>; +type DetailsResult = Awaited>; +type ResolveResult = Awaited>; +type NearbyResult = Awaited>; + +const t = (key: string) => key; + +const BOX = { low: { lat: 48, lng: 2 }, high: { lat: 49, lng: 3 } }; + +const LOUVRE = { name: 'Louvre', address: 'Rue de Rivoli', lat: 48.86, lng: 2.33, osm_id: 'W1' }; +const ORSAY = { name: 'Orsay', address: 'Quai', lat: 48.85, lng: 2.32, google_place_id: 'g-orsay' }; +const SUGGESTION = { placeId: 'sug-1', mainText: 'Louvre', secondaryText: 'Paris' }; + +const makeToast = () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }); + +let toast: ReturnType; + +function setup(variant: 'dialog' | 'sheet', over: Partial = {}) { + const onPlace = vi.fn(); + const onSuggestionName = vi.fn(); + const onMapLink = vi.fn(); + const onCoordinates = vi.fn(); + const onResolvingChange = vi.fn(); + const hook = renderHook( + (p: { open?: boolean }) => + usePlaceSearch({ + variant, + locationBias: BOX, + t, + toast, + open: p.open, + onPlace, + onSuggestionName, + onMapLink, + onCoordinates, + onResolvingChange, + ...over, + }), + { initialProps: { open: variant === 'dialog' ? true : undefined } } + ); + return { ...hook, onPlace, onSuggestionName, onMapLink, onCoordinates, onResolvingChange }; +} + +type Hook = { current: ReturnType }; + +function mockSearch(places: Record[] = [LOUVRE, ORSAY], source = 'osm') { + return vi.spyOn(mapsApi, 'search').mockResolvedValue({ places, source } as unknown as SearchResult); +} + +function mockAutocomplete(suggestions: Record[] = [SUGGESTION], source = 'osm') { + return vi.spyOn(mapsApi, 'autocomplete').mockResolvedValue({ suggestions, source } as unknown as AutocompleteResult); +} + +async function searchFor(result: Hook, query: string, provider?: 'google') { + act(() => result.current.setQuery(query)); + await act(async () => { + await result.current.runSearch(provider); + }); +} + +beforeEach(() => { + resetAllStores(); + toast = makeToast(); + vi.mocked(recordPlacePick).mockClear(); + // A typed query fires a debounced request; tests that do not look at it get an empty list. + mockAutocomplete([]); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('usePlaceSearch: typed-ahead suggestions', () => { + it('FE-PLANNER-PLSEARCH-001: asks after the debounce with the language, the bias box and a session token', async () => { + const spy = mockAutocomplete(); + const { result } = setup('dialog'); + act(() => result.current.setQuery(' Lo ')); + await waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); + expect(spy).toHaveBeenCalledWith('Lo', 'en', BOX, expect.any(AbortSignal), expect.any(String)); + await waitFor(() => expect(result.current.suggestions).toEqual([SUGGESTION])); + expect(result.current.acSource).toBe('osm'); + expect(result.current.highlight).toBe(-1); + }); + + it('FE-PLANNER-PLSEARCH-002: stays quiet below two characters and for a map link', async () => { + const spy = mockAutocomplete(); + const { result } = setup('dialog'); + act(() => result.current.setQuery('L')); + act(() => result.current.setQuery('https://maps.app.goo.gl/abc')); + await new Promise((r) => setTimeout(r, 400)); + expect(spy).not.toHaveBeenCalled(); + expect(result.current.suggestions).toEqual([]); + }); + + it('FE-PLANNER-PLSEARCH-003: a coordinate pair gets suggestions on the desktop but not on the phone', async () => { + const spy = mockAutocomplete(); + const desktop = setup('dialog'); + act(() => desktop.result.current.setQuery('48.85, 2.35')); + await waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); + desktop.unmount(); + + spy.mockClear(); + const phone = setup('sheet'); + act(() => phone.result.current.setQuery('48.85, 2.35')); + await new Promise((r) => setTimeout(r, 400)); + expect(spy).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLSEARCH-004: a failed list is logged on the desktop only and empties the list on both', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(mapsApi, 'autocomplete').mockRejectedValue(new Error('boom')); + const desktop = setup('dialog'); + await act(async () => { + await desktop.result.current.fetchSuggestions('Louvre'); + }); + expect(error).toHaveBeenCalledWith('Autocomplete failed:', expect.any(Error)); + expect(desktop.result.current.suggestions).toEqual([]); + desktop.unmount(); + + error.mockClear(); + const phone = setup('sheet'); + await act(async () => { + await phone.result.current.fetchSuggestions('Louvre'); + }); + expect(error).not.toHaveBeenCalled(); + expect(phone.result.current.suggestions).toEqual([]); + }); + + it('FE-PLANNER-PLSEARCH-005: an aborted request leaves the desktop list alone but empties the phone list', async () => { + const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' }); + const spy = mockAutocomplete(); + const desktop = setup('dialog'); + await act(async () => { + await desktop.result.current.fetchSuggestions('Louvre'); + }); + spy.mockRejectedValue(aborted); + await act(async () => { + await desktop.result.current.fetchSuggestions('Louvre M'); + }); + expect(desktop.result.current.suggestions).toEqual([SUGGESTION]); + desktop.unmount(); + + spy.mockResolvedValue({ suggestions: [SUGGESTION], source: 'osm' } as unknown as AutocompleteResult); + const phone = setup('sheet'); + await act(async () => { + await phone.result.current.fetchSuggestions('Louvre'); + }); + spy.mockRejectedValue(aborted); + await act(async () => { + await phone.result.current.fetchSuggestions('Louvre M'); + }); + expect(phone.result.current.suggestions).toEqual([]); + }); +}); + +describe('usePlaceSearch: full search', () => { + it('FE-PLANNER-PLSEARCH-006: searches the trimmed query biased to the centre of the box', async () => { + const spy = mockSearch(); + const { result, onResolvingChange } = setup('dialog'); + await searchFor(result, ' Louvre '); + expect(spy).toHaveBeenCalledWith('Louvre', 'en', expect.objectContaining({ lat: 48.5, lng: 2.5 }), undefined); + expect(result.current.results).toEqual([LOUVRE, ORSAY]); + expect(result.current.searchSource).toBe('osm'); + expect(result.current.nearbyList).toBe(false); + expect(result.current.emptySearch).toBeNull(); + expect(result.current.searching).toBe(false); + expect(onResolvingChange.mock.calls).toEqual([[true], [false]]); + }); + + it('FE-PLANNER-PLSEARCH-007: an empty answer names the query on the desktop only', async () => { + mockSearch([]); + const desktop = setup('dialog'); + await searchFor(desktop.result, 'Nowhere'); + expect(desktop.result.current.emptySearch).toBe('Nowhere'); + desktop.unmount(); + + const phone = setup('sheet'); + await searchFor(phone.result, 'Nowhere'); + expect(phone.result.current.emptySearch).toBeNull(); + }); + + it('FE-PLANNER-PLSEARCH-008: the Google retry sends the query the list came from', async () => { + const spy = mockSearch(); + const { result } = setup('dialog'); + await searchFor(result, 'Louvre'); + act(() => result.current.setQuery('something else')); + await act(async () => { + await result.current.runSearch('google'); + }); + expect(spy).toHaveBeenLastCalledWith('Louvre', 'en', expect.anything(), 'google'); + }); + + it('FE-PLANNER-PLSEARCH-009: the phone takes a typed coordinate pair as the position, without a request', async () => { + const spy = mockSearch(); + const { result, onCoordinates } = setup('sheet'); + await searchFor(result, '48.85, 2.35'); + expect(spy).not.toHaveBeenCalled(); + expect(onCoordinates).toHaveBeenCalledWith('48.85', '2.35'); + expect(result.current.query).toBe(''); + }); + + it('FE-PLANNER-PLSEARCH-010: the desktop searches a coordinate pair like any other text', async () => { + const spy = mockSearch(); + const { result, onCoordinates } = setup('dialog'); + await searchFor(result, '48.85, 2.35'); + expect(spy).toHaveBeenCalledWith('48.85, 2.35', 'en', expect.anything(), undefined); + expect(onCoordinates).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLSEARCH-011: a map link resolves into a position; only the desktop clears its list', async () => { + const resolved = { name: 'Louvre', address: 'Paris', lat: 48.86, lng: 2.33, google_ftid: 'ft' }; + vi.spyOn(mapsApi, 'resolveUrl').mockResolvedValue(resolved as unknown as ResolveResult); + mockSearch(); + const link = 'https://maps.app.goo.gl/abc'; + + const desktop = setup('dialog'); + await searchFor(desktop.result, 'Louvre'); + await searchFor(desktop.result, link); + expect(desktop.onMapLink).toHaveBeenCalledWith(resolved); + expect(toast.success).toHaveBeenCalledWith('places.urlResolved'); + expect(desktop.result.current.results).toEqual([]); + expect(desktop.result.current.query).toBe(''); + desktop.unmount(); + + const phone = setup('sheet'); + await searchFor(phone.result, 'Louvre'); + await searchFor(phone.result, link); + expect(phone.onMapLink).toHaveBeenCalledWith(resolved); + expect(phone.result.current.results).toEqual([LOUVRE, ORSAY]); + expect(phone.result.current.query).toBe(''); + }); + + it('FE-PLANNER-PLSEARCH-012: a failed search toasts the server message or the fallback', async () => { + vi.spyOn(mapsApi, 'search').mockRejectedValue(new Error('down')); + const { result } = setup('sheet'); + await searchFor(result, 'Louvre'); + expect(toast.error).toHaveBeenCalledTimes(1); + expect(result.current.searching).toBe(false); + }); + + it('FE-PLANNER-PLSEARCH-013: the phone ends the billing session after a full search, the desktop keeps it', async () => { + const auto = mockAutocomplete(); + mockSearch(); + const tokensAround = async (variant: 'dialog' | 'sheet') => { + auto.mockClear(); + const { result, unmount } = setup(variant); + await act(async () => { + await result.current.fetchSuggestions('Louvre'); + }); + await searchFor(result, 'Louvre'); + await act(async () => { + await result.current.fetchSuggestions('Louvre M'); + }); + unmount(); + return auto.mock.calls.map((call) => call[4]); + }; + const [desktopFirst, desktopSecond] = await tokensAround('dialog'); + expect(desktopSecond).toBe(desktopFirst); + const [phoneFirst, phoneSecond] = await tokensAround('sheet'); + expect(phoneSecond).not.toBe(phoneFirst); + }); +}); + +describe('usePlaceSearch: picking', () => { + it('FE-PLANNER-PLSEARCH-014: a search row goes to the form with its mark and is logged with its rank', async () => { + mockSearch(); + const { result, onPlace } = setup('dialog'); + await searchFor(result, 'Louvre'); + act(() => result.current.pickPlace(ORSAY, { mode: 'search', rank: 1, count: 2 })); + expect(onPlace.mock.calls[0][0]).toBe(ORSAY); + expect(recordPlacePick).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'Louvre', + lang: 'en', + biasLat: 48.5, + biasLng: 2.5, + source: 'search:osm', + liveRank: 1, + liveCount: 2, + pickedName: 'Orsay', + pickedLat: 48.85, + pickedLng: 2.32, + pickedPlaceId: 'g-orsay', + }) + ); + expect(result.current.results).toEqual([]); + expect(result.current.query).toBe(''); + }); + + it('FE-PLANNER-PLSEARCH-015: a place from elsewhere carries no mark and is not logged', () => { + const { result, onPlace } = setup('dialog'); + act(() => result.current.pickPlace(LOUVRE)); + expect(onPlace).toHaveBeenCalledWith(LOUVRE, null); + expect(recordPlacePick).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLSEARCH-016: the phone clears the typed-ahead list on a pick, the desktop leaves it', async () => { + mockAutocomplete(); + const desktop = setup('dialog'); + await act(async () => { + await desktop.result.current.fetchSuggestions('Louvre'); + }); + act(() => desktop.result.current.pickPlace(LOUVRE)); + expect(desktop.result.current.suggestions).toEqual([SUGGESTION]); + desktop.unmount(); + + const phone = setup('sheet'); + await act(async () => { + await phone.result.current.fetchSuggestions('Louvre'); + }); + act(() => phone.result.current.pickPlace(LOUVRE)); + expect(phone.result.current.suggestions).toEqual([]); + }); + + it('FE-PLANNER-PLSEARCH-017: a suggestion fills the name first, then its looked up place', async () => { + mockAutocomplete(); + const details = vi.spyOn(mapsApi, 'details').mockResolvedValue({ place: LOUVRE } as unknown as DetailsResult); + const { result, onSuggestionName, onPlace } = setup('sheet'); + await act(async () => { + await result.current.fetchSuggestions('Louvre'); + }); + await act(async () => { + await result.current.selectSuggestion(SUGGESTION); + }); + expect(onSuggestionName).toHaveBeenCalledWith('Louvre'); + expect(details).toHaveBeenCalledWith('sug-1', 'en', expect.any(String)); + expect(onPlace.mock.calls[0][0]).toBe(LOUVRE); + expect(recordPlacePick).toHaveBeenCalledWith(expect.objectContaining({ source: 'autocomplete:osm', liveRank: 0 })); + expect(result.current.searching).toBe(false); + }); + + it('FE-PLANNER-PLSEARCH-018: an OpenStreetMap row without details uses its own position, never a text search', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(mapsApi, 'details').mockRejectedValue(new Error('no details')); + const search = mockSearch(); + const row = { + placeId: 'N5', + mainText: 'Tokyo Station', + secondaryText: '東京駅', + source: 'openstreetmap', + lat: 35.68, + lng: 139.77, + }; + const { result, onPlace } = setup('dialog'); + await act(async () => { + await result.current.selectSuggestion(row); + }); + expect(search).not.toHaveBeenCalled(); + expect(onPlace.mock.calls[0][0]).toEqual({ + name: 'Tokyo Station', + address: '', + lat: 35.68, + lng: 139.77, + osm_id: 'N5', + source: 'openstreetmap', + }); + }); + + it('FE-PLANNER-PLSEARCH-019: a suggestion nothing answers for puts the query back and says so', async () => { + vi.spyOn(mapsApi, 'details').mockResolvedValue({ place: null } as unknown as DetailsResult); + const search = mockSearch([]); + const { result, onPlace } = setup('sheet'); + act(() => result.current.setQuery('Louvre')); + await act(async () => { + await result.current.selectSuggestion(SUGGESTION); + }); + expect(search).toHaveBeenCalledWith('Louvre, Paris', 'en', expect.anything()); + expect(onPlace).not.toHaveBeenCalled(); + expect(result.current.query).toBe('Louvre'); + expect(toast.error).toHaveBeenCalledWith('places.mapsSearchError'); + }); +}); + +describe('usePlaceSearch: the desktop dialog', () => { + it('FE-PLANNER-PLSEARCH-020: closing clears the search, and an answer arriving after it is dropped', async () => { + let answer: (value: SearchResult) => void = () => {}; + vi.spyOn(mapsApi, 'search').mockReturnValue(new Promise((resolve) => (answer = resolve))); + const { result, rerender } = setup('dialog'); + act(() => result.current.setQuery('Louvre')); + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.runSearch(); + }); + expect(result.current.searching).toBe(true); + rerender({ open: false }); + expect(result.current.query).toBe(''); + expect(result.current.searching).toBe(false); + await act(async () => { + answer({ places: [LOUVRE], source: 'osm' } as unknown as SearchResult); + await pending; + }); + expect(result.current.results).toEqual([]); + }); + + it('FE-PLANNER-PLSEARCH-021: places near the pin replace the list and say when there are none', async () => { + const nearby = vi + .spyOn(mapsApi, 'nearby') + .mockResolvedValue({ places: [], source: 'osm' } as unknown as NearbyResult); + const { result } = setup('dialog'); + await act(async () => { + await result.current.searchNearby(null); + }); + expect(nearby).not.toHaveBeenCalled(); + await act(async () => { + await result.current.searchNearby({ lat: 48.8, lng: 2.3 }); + }); + expect(nearby).toHaveBeenCalledWith(48.8, 2.3, 'en'); + expect(result.current.nearbyList).toBe(true); + expect(toast.info).toHaveBeenCalledWith('places.nearbyNone'); + }); + + it('FE-PLANNER-PLSEARCH-022: arrows walk the list, Enter picks the highlighted row, Escape closes the list', async () => { + mockAutocomplete([SUGGESTION, { placeId: 'sug-2', mainText: 'Orsay', secondaryText: '' }]); + vi.spyOn(mapsApi, 'details').mockResolvedValue({ place: ORSAY } as unknown as DetailsResult); + const { result, onPlace } = setup('dialog'); + const key = (k: string) => ({ key: k, preventDefault: vi.fn() }) as unknown as React.KeyboardEvent; + await act(async () => { + await result.current.fetchSuggestions('Lou'); + }); + act(() => result.current.handleKeyDown(key('ArrowDown'))); + act(() => result.current.handleKeyDown(key('ArrowDown'))); + expect(result.current.highlight).toBe(1); + act(() => result.current.handleKeyDown(key('ArrowUp'))); + expect(result.current.highlight).toBe(0); + act(() => result.current.handleKeyDown(key('Escape'))); + expect(result.current.suggestions).toEqual([]); + expect(result.current.highlight).toBe(-1); + + await act(async () => { + await result.current.fetchSuggestions('Lou'); + }); + act(() => result.current.handleKeyDown(key('ArrowUp'))); + await act(async () => { + result.current.handleKeyDown(key('Enter')); + }); + await waitFor(() => expect(onPlace).toHaveBeenCalledTimes(1)); + expect(onPlace.mock.calls[0][0]).toBe(ORSAY); + }); +}); diff --git a/client/src/components/Planner/usePlaceSearch.ts b/client/src/components/Planner/usePlaceSearch.ts new file mode 100644 index 000000000..bb66620fa --- /dev/null +++ b/client/src/components/Planner/usePlaceSearch.ts @@ -0,0 +1,469 @@ +import type { MapsResolveUrlResult } from '@trek/shared'; +import type React from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { mapsApi } from '../../api/client'; +import { recordPlacePick } from '../../api/placeShadow'; +import { pointFromBox, type LocationBiasBox } from '../../hooks/useLocationBias'; +import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'; +import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'; +import type { useTranslation } from '../../i18n'; +import { useAuthStore } from '../../store/authStore'; +import { getApiErrorMessage } from '../../utils/apiError'; +import { corePickRank, selectGoogleHoldsSlot } from '../../utils/placeSource'; +import { PlacesSession } from '../../utils/placesSession'; +import type { useToast } from '../shared/Toast'; +import { isMapUrl, parseCoordinatePair } from './PlaceFormModal.helpers'; + +/** A place as a provider hands it back: loosely typed, kept open by design. */ +export type MapsPlace = Record; + +/** A row of a full or nearby search, read the way both lists render it. */ +export type MapsSearchRow = MapsPlace & { name?: string; address?: string; distance_m?: number }; + +/** + * One row of the typed-ahead list, as the server sends it. + * + * `source`, `lat` and `lng` are optional because not every index fills them: + * Google answers with neither, and the mark falls back to the name the whole + * list carries. `place` is only on a plugin's row, which brings its whole place + * along because no details lookup knows a plugin id (#2221). + */ +export interface PlaceSuggestion { + placeId: string; + mainText: string; + secondaryText: string; + source?: string; + lat?: number; + lng?: number; + place?: MapsPlace; +} + +/** Where a pick sat in a ranked list, for the shadow log. */ +export interface PlaceSearchPick { + mode: 'search' | 'autocomplete'; + rank: number; + count: number; +} + +export interface PlaceSearchOptions { + /** + * The two shells search a little differently, and each keeps its own way. + * + * `dialog` (desktop): the search button and the keys clear the typed-ahead list + * themselves, failures are logged to the console and an aborted keystroke leaves its + * list alone, a query cut below two characters ends the billing session, a full search + * leaves the session open, and a resolved map link clears the result list as well. + * + * `sheet` (phone): a typed "lat, lng" pair is a position rather than a query, so it + * gets no suggestions and the search button takes it as the coordinates; every full + * search ends the billing session, and a pick clears the typed-ahead list too. + */ + variant: 'dialog' | 'sheet'; + /** Where the trip is happening: autocomplete is biased to the box, the full search to its centre. */ + locationBias: LocationBiasBox | undefined; + t: ReturnType['t']; + toast: ReturnType; + /** + * The desktop dialog stays mounted while it is closed, so each closing ends what the + * search held: the query, its lists, a request still on its way. The phone's sheet + * unmounts instead and passes nothing. + */ + open?: boolean; + /** A place picked from a list or found for a suggestion; `label` names the index it came from. */ + onPlace: (place: MapsPlace, label: string | null) => void; + /** A suggestion was clicked: its name goes into the form while its place is looked up. */ + onSuggestionName: (name: string) => void; + /** A pasted Google Maps or Amap link the server turned into a position. */ + onMapLink: (resolved: MapsResolveUrlResult) => void; + /** A typed coordinate pair, taken as the position itself (the phone). */ + onCoordinates?: (lat: string, lng: string) => void; + /** True while a search or a pick is being looked up. */ + onResolvingChange?: (resolving: boolean) => void; +} + +/** + * The maps search of the place form: the one logic path behind the desktop dialog's + * search block and the phone sheet's search row, which render their own markup over it. + * Typed-ahead suggestions (debounced, the stale request aborted, one Google billing + * session per search), a full search biased to the trip, a pasted map link resolved into + * a position, places near the pin, and picking a row, which hands the place to the form + * and records the pick for the shadow log. + */ +export function usePlaceSearch({ + variant, + locationBias, + t, + toast, + open, + onPlace, + onSuggestionName, + onMapLink, + onCoordinates, + onResolvingChange, +}: PlaceSearchOptions) { + const sheet = variant === 'sheet'; + // Place names in the language the user picked for them, the app's otherwise (#1799). + const language = usePlaceLanguage(); + const googleAnswers = useAuthStore(selectGoogleHoldsSlot); + const { autocomplete, sourceLabel } = usePlaceSuggestions(); + // The same hint autocomplete gets, as a point instead of a box. + const biasPoint = pointFromBox(locationBias); + + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + /** What answered the last full search. Only a fallback: a merged list carries the source per place. */ + const [searchSource, setSearchSource] = useState(''); + // The list on screen answers "what is near the pin" rather than a typed query (#976). + const [nearbyList, setNearbyList] = useState(false); + // The query the last full search found nothing for (#2472): the cue to add the + // place by hand instead of a silent empty list. + const [emptySearch, setEmptySearch] = useState(null); + const [searching, setSearching] = useState(false); + const [suggestions, setSuggestions] = useState([]); + // Which index answered the last keystroke, for the rows that do not say so + // themselves. Google and the OpenStreetMap fallback each answer from one + // place; the index path answers from two at once and marks every row. + const [acSource, setAcSource] = useState(''); + const [highlight, setHighlight] = useState(-1); + /** + * What produced the list currently on screen, kept for the shadow log: the + * query as typed and the provider the envelope named. A ref rather than + * state because nothing renders from it and a pick must read the value that + * belonged to the list, not a value a re-render replaced. + */ + const searchMetaRef = useRef<{ query: string; source: string } | null>(null); + const acMetaRef = useRef<{ query: string; source: string } | null>(null); + const debounceRef = useRef | null>(null); + const abortRef = useRef(null); + // Counts the closings of the dialog, so an answer still on its way when it + // closed can tell that the opening it was asked for is over. A counter rather + // than an abort, because the full search takes no signal. + const epochRef = useRef(0); + // Ties one search's keystrokes and its details lookup into a single Google + // billing session (see utils/placesSession). + const sessionRef = useRef(new PlacesSession()); + + const setBusy = (busy: boolean) => { + setSearching(busy); + onResolvingChange?.(busy); + }; + + // Everything the search block holds would otherwise greet the next opening: + // the last query, its list, the Google line offering that list's query again, + // suggestions still on their way. A pick from that list writes over the place + // being edited, whichever place that is by then. + useEffect(() => { + if (open !== false) return; + epochRef.current += 1; + if (debounceRef.current) clearTimeout(debounceRef.current); + abortRef.current?.abort(); + sessionRef.current.end(); + searchMetaRef.current = null; + acMetaRef.current = null; + setQuery(''); + setResults([]); + setSearchSource(''); + setSuggestions([]); + setAcSource(''); + setHighlight(-1); + setSearching(false); + }, [open]); + + // Autocomplete fetch: aborts any in-flight request before starting a new one. + const fetchSuggestions = useCallback( + async (input: string) => { + if (input.length < 2 || isMapUrl(input)) { + setSuggestions([]); + setHighlight(-1); + return; + } + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + try { + const result = await autocomplete( + input, + language, + locationBias, + controller.signal, + sessionRef.current.current() + ); + acMetaRef.current = { query: input, source: result.source || 'unknown' }; + setSuggestions(result.suggestions || []); + setAcSource(result.source || ''); + setHighlight(-1); + } catch (err: unknown) { + if (!sheet && err instanceof Error && err.name === 'AbortError') return; + // Superseded request: axios rejects an aborted call with CanceledError. + if (err instanceof Error && err.name === 'CanceledError') return; + if (!sheet) console.error('Autocomplete failed:', err); + setSuggestions([]); + } + }, + [autocomplete, language, locationBias, sheet] + ); + + // Debounced autocomplete, watching only the query. Map links go to the search + // button, and so do coordinate pairs on the phone. + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + const trimmed = query.trim(); + if (trimmed.length < 2 || isMapUrl(trimmed) || (sheet && parseCoordinatePair(trimmed) !== null)) { + // A list still on its way belongs to a query that is gone. + abortRef.current?.abort(); + setSuggestions([]); + if (!sheet) { + setHighlight(-1); + sessionRef.current.end(); + } + return; + } + debounceRef.current = setTimeout(() => fetchSuggestions(trimmed), 300); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query, fetchSuggestions, sheet]); + + /** Hands a place to the form, logs where it was picked, and clears what led to it. */ + const take = (place: MapsPlace, pick: PlaceSearchPick | undefined, label: string | null) => { + onPlace(place, label); + // `pick` is present only when the place came out of a ranked list. The + // collection picker, a coordinate pair and a resolved link reach the form + // with a place that was never ranked against a query, and a made-up rank + // would be worse than no row at all. + if (pick) { + const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current; + const lat = Number(place.lat); + const lng = Number(place.lng); + if (meta && Number.isFinite(lat) && Number.isFinite(lng)) { + recordPlacePick({ + query: meta.query, + lang: language, + // The bias the search actually ran under is a box around the trip's + // existing places; the corpus stores its centre, which is what an + // evaluation needs to bias its own index the same way. + biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined, + biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined, + source: `${pick.mode}:${meta.source}`, + liveRank: pick.rank, + liveCount: pick.count, + pickedName: sheet ? String(place.name ?? '') : (place.name as string) || '', + pickedLat: lat, + pickedLng: lng, + pickedPlaceId: + (place.google_place_id as string) || (place.amap_poi_id as string) || (place.osm_id as string) || null, + }); + } + } + setResults([]); + if (sheet) setSuggestions([]); + setQuery(''); + }; + + /** A row of a list, or a place from elsewhere (the saved-place picker) when `pick` is absent. */ + const pickPlace = (place: MapsPlace, pick?: PlaceSearchPick) => + take(place, pick, sourceLabel(place, pick?.mode === 'search' ? searchSource : '')); + + const runSearch = async (provider?: 'google') => { + // The retry sends the query the list came from, not the field: the list + // stays on screen while the field is edited or cleared, and the line under + // it promises the same query. + const trimmed = provider ? (searchMetaRef.current?.query ?? '') : query.trim(); + if (!trimmed) return; + if (sheet) { + setSuggestions([]); + // "lat, lng" typed or pasted: straight to coordinates, no lookup needed. + const pair = parseCoordinatePair(trimmed); + if (pair) { + onCoordinates?.(pair[0], pair[1]); + setQuery(''); + return; + } + } + const epoch = epochRef.current; + setBusy(true); + try { + // A pasted Google Maps or Amap link resolves server-side into a place + if (!provider && isMapUrl(trimmed)) { + const resolved = await mapsApi.resolveUrl(trimmed); + if (epoch !== epochRef.current) return; + if (resolved.lat && resolved.lng) { + onMapLink(resolved); + if (!sheet) setResults([]); + setQuery(''); + toast.success(t('places.urlResolved')); + return; + } + } + const result = await mapsApi.search(trimmed, language, biasPoint, provider); + if (epoch !== epochRef.current) return; + searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }; + const places = (result.places || []) as MapsSearchRow[]; + if (!sheet) setNearbyList(false); + setResults(places); + if (!sheet) setEmptySearch(places.length === 0 ? trimmed : null); + setSearchSource(result.source || ''); + } catch (err: unknown) { + if (epoch !== epochRef.current) return; + toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))); + } finally { + if (epoch === epochRef.current) setBusy(false); + if (sheet) sessionRef.current.end(); + } + }; + + // What is around the pin the form holds (#976): the list a search would show, + // nearest first. Not logged as a search pick, because no query was typed. + const searchNearby = async (pin: { lat: number; lng: number } | null) => { + if (!pin) return; + const epoch = epochRef.current; + setBusy(true); + setSuggestions([]); + try { + const result = await mapsApi.nearby(pin.lat, pin.lng, language); + if (epoch !== epochRef.current) return; + searchMetaRef.current = null; + setNearbyList(true); + setResults((result.places || []) as MapsSearchRow[]); + setSearchSource(result.source || ''); + if (!result.places?.length) toast.info(t('places.nearbyNone')); + } catch (err: unknown) { + if (epoch !== epochRef.current) return; + toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))); + } finally { + if (epoch === epochRef.current) setBusy(false); + } + }; + + const selectSuggestion = async (suggestion: PlaceSuggestion) => { + // Read before the list is cleared: this is the rank the user saw. + const acPick = corePickRank(suggestions, suggestion); + abortRef.current?.abort(); + setSuggestions([]); + setHighlight(-1); + const previousQuery = query; + const epoch = epochRef.current; + setQuery(''); + onSuggestionName(suggestion.mainText); + setBusy(true); + try { + // The details lookup is a fragile second hop: it can fail when the + // details kill-switch is off, when the OSM Overpass mirror is overloaded, + // or on any upstream error. Treat a missing/coordinate-less place as a + // miss and fall back to the reliable text-search path the search button + // uses (its results already carry coordinates), so dropdown items stay + // clickable instead of dead-ending on "Place search failed". (#1192) + let place: MapsPlace | null = suggestion.place ?? null; + if (!place) { + try { + // Spends the session the suggestions opened, so Google bills the search + // once rather than per keystroke. + const result = await mapsApi.details(suggestion.placeId, language, sessionRef.current.peek()); + if (result.place && result.place.lat != null && result.place.lng != null) place = result.place; + } catch (err) { + if (!sheet) console.error('Failed to fetch place details:', err); + } + } + // Closed while the details were on their way: the pick belongs to an + // opening that is over, and the fallback search below is not worth a + // request nobody will see. + if (epoch !== epochRef.current) return; + if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) { + // The layer's rows carry no address; their second line is the name + // written on the building. Searching for "Tokio Hauptbahnhof, 東京駅" + // is not a question anybody asked, and its first answer would be + // whatever the index made of it: a different place, chosen silently. + // The suggestion already knows where it is, so use that. + place = { + name: suggestion.mainText, + address: '', + lat: suggestion.lat, + lng: suggestion.lng, + osm_id: suggestion.placeId, + source: 'openstreetmap', + }; + } + if (!place) { + const fullQuery = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', '); + const search = await mapsApi.search(fullQuery, language, biasPoint); + if (epoch !== epochRef.current) return; + place = search.places?.[0] ?? null; + } + if (place) { + // Named after the row that was clicked, whatever the lookup behind it answered from. + take(place, acPick && { mode: 'autocomplete', ...acPick }, sourceLabel(suggestion, acSource)); + } else { + setQuery(previousQuery); + toast.error(t('places.mapsSearchError')); + } + } catch (err) { + if (epoch !== epochRef.current) return; + if (!sheet) console.error('Place suggestion lookup failed:', err); + setQuery(previousQuery); + toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))); + } finally { + // Closing already ended this session; ending it again here would cut off + // the one a new opening may have started meanwhile. + if (epoch === epochRef.current) { + setBusy(false); + sessionRef.current.end(); + } + } + }; + + /** The desktop field's keys: arrows walk the typed-ahead list, Enter picks or searches, Escape closes the list. */ + const handleKeyDown = (e: React.KeyboardEvent) => { + if (suggestions.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlight((prev) => (prev + 1) % suggestions.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlight((prev) => (prev <= 0 ? suggestions.length - 1 : prev - 1)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (highlight >= 0) { + void selectSuggestion(suggestions[highlight]); + } else { + setSuggestions([]); + void runSearch(); + } + } else if (e.key === 'Escape') { + // Spent on the list: the dialog leaves an Escape that was already + // handled alone, so only the next one closes it. + e.preventDefault(); + setSuggestions([]); + setHighlight(-1); + } + } else if (e.key === 'Enter') { + e.preventDefault(); + void runSearch(); + } + }; + + return { + language, + googleAnswers, + sourceLabel, + query, + setQuery, + results, + searchSource, + nearbyList, + emptySearch, + setEmptySearch, + searching, + suggestions, + setSuggestions, + acSource, + highlight, + setHighlight, + fetchSuggestions, + runSearch, + searchNearby, + pickPlace, + selectSuggestion, + handleKeyDown, + }; +} diff --git a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx index 210426031..0d5a3bdb0 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx @@ -3,10 +3,15 @@ import { MapPin } from 'lucide-react' import MSheet from '../../../components/MSheet' import { DEFAULT_FORM, + endsBeforeStart, mergeResult, + parseCoordinatePair, + placeEditForm, + placeFormPayload, + prefillForm, type PlaceFormData, - type ResultField, } from '../../../../components/Planner/PlaceFormModal.helpers' +import { usePlaceForm } from '../../../../components/Planner/usePlaceForm' import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome' import { useAddonStore } from '../../../../store/addonStore' import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types' @@ -15,7 +20,7 @@ import PlCategoryPicker from './PlCategoryPicker' import PlTimeFields from './PlTimeFields' import PlFileAttach from './PlFileAttach' import MLinkedCosts from './MLinkedCosts' -import PlaceDetailsColumn, { type PlaceDetailsSelection } from '../../../../components/Planner/PlaceDetailsColumn' +import PlaceDetailsColumn from '../../../../components/Planner/PlaceDetailsColumn' import { useTranslation } from '../../../../i18n' import { useAuthStore } from '../../../../store/authStore' import { useSettingsStore } from '../../../../store/settingsStore' @@ -29,30 +34,6 @@ export interface MPlaceEditSheetProps { onOpenExpense: (req: BookingExpenseRequest) => void } -// #1152: same duplicate heuristic as the desktop form — shared Google Place ID, -// case-insensitive name match, or near-identical coordinates (~11 m). -const DUP_COORD_TOLERANCE = 0.0001 - -function findDuplicateName( - form: PlaceFormData, - places: { name?: string | null; lat?: number | string | null; lng?: number | string | null; google_place_id?: string | null }[], -): string | null { - const name = form.name.trim().toLowerCase() - const gid = (form.google_place_id || '').trim() - const lat = form.lat ? Number.parseFloat(form.lat) : null - const lng = form.lng ? Number.parseFloat(form.lng) : null - for (const p of places || []) { - if (gid && p.google_place_id && p.google_place_id === gid) return p.name || form.name - if (name && p.name && p.name.trim().toLowerCase() === name) return p.name - if ( - lat != null && lng != null && p.lat != null && p.lng != null && - Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE && - Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE - ) return p.name || form.name - } - return null -} - /** * The visit the editor was opened on, looked up in the planner's STORED list. * @@ -86,13 +67,14 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh handleSavePlace, setDeletePlaceId, confirmDeletePlace, } = planner - const [form, setForm] = useState(DEFAULT_FORM) - // Which fields the last picked search result wrote. See mergeResult. - const autoFilledRef = useRef>(new Set()) - const [pendingFiles, setPendingFiles] = useState([]) - const [isSaving, setIsSaving] = useState(false) + // The fields, which of them the last picked search result wrote (see mergeResult), the + // pending files and the details block follow the same rules as the desktop dialog. + const { + form, setForm, autoFilledRef, pendingFiles, addFiles, removeFile, handlePaste, isSaving, setIsSaving, + duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange, pickImage, adoptDescription, + warnIfDuplicate, + } = usePlaceForm({ t, toast, canUploadFiles }) const [resolvingPick, setResolvingPick] = useState(false) - const [duplicateWarning, setDuplicateWarning] = useState(null) const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) // Set right before submit: the place has to exist before an expense can point // at it, exactly like MReservationSheet does it. @@ -104,10 +86,6 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh const [sheetPlace, setSheetPlace] = useState(null) const [sheetAssignmentId, setSheetAssignmentId] = useState(null) - // The details block under the search field follows the same selection rules - // as the desktop dialog's left column: the picked search result, else the - // place being edited, else whatever a map POI prefilled. - const [detailsSelection, setDetailsSelection] = useState(null) const placesEnrichEnabled = useAuthStore(s => s.placesEnrichEnabled) const { language, locale } = useTranslation() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' @@ -130,76 +108,18 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh }, [sheetPlace, assignments, ctxAssignment]) // Prefill on open — same source order as the desktop form: editing place - // (times off the in-context assignment), map/POI prefill coords, blank. + // (times off the in-context assignment), map/POI prefill coords, blank. The + // details block follows the same order, so it never keeps showing the previous + // sheet's place. useEffect(() => { if (!showPlaceForm) return setSheetPlace(editingPlace) setSheetAssignmentId(editingAssignmentId) - if (editingPlace) { - const assignment = findVisit(storedAssignments, editingAssignmentId) - const timeSource = assignment?.place ?? editingPlace - setForm({ - name: editingPlace.name || '', - description: editingPlace.description || '', - address: editingPlace.address || '', - lat: editingPlace.lat != null ? String(editingPlace.lat) : '', - lng: editingPlace.lng != null ? String(editingPlace.lng) : '', - category_id: editingPlace.category_id != null ? String(editingPlace.category_id) : '', - place_time: timeSource.place_time || '', - end_time: timeSource.end_time || '', - notes: editingPlace.notes || '', - transport_mode: editingPlace.transport_mode || 'walking', - website: editingPlace.website || '', - // The day-specific note rides only with an assignment in context (#2163); - // otherwise the key stays absent so submit never sends a notes write. - ...(assignment ? { assignment_notes: assignment.notes || '' } : {}), - }) - } else if (prefillCoords) { - setForm({ - ...DEFAULT_FORM, - lat: String(prefillCoords.lat), - lng: String(prefillCoords.lng), - name: prefillCoords.name || '', - address: prefillCoords.address || '', - website: prefillCoords.website || '', - phone: prefillCoords.phone || '', - osm_id: prefillCoords.osm_id, - }) - } else { - setForm(DEFAULT_FORM) - } - // Same source order as the desktop dialog: an existing place's provider id - // and coordinates, a map POI's prefill, or nothing. Without this the block - // would keep showing the previous sheet's place. - if (editingPlace && editingPlace.lat != null && editingPlace.lng != null) { - setDetailsSelection({ - placeId: editingPlace.google_place_id || editingPlace.amap_poi_id || editingPlace.osm_id || undefined, - lat: Number(editingPlace.lat), - lng: Number(editingPlace.lng), - name: editingPlace.name || '', - }) - } else if (prefillCoords) { - setDetailsSelection({ - placeId: prefillCoords.osm_id || undefined, - lat: prefillCoords.lat, - lng: prefillCoords.lng, - name: prefillCoords.name || '', - }) - } else { - setDetailsSelection(null) - } - // A fresh sheet owns nothing yet; one opened on a map POI owns whatever - // that POI filled in. An existing place being edited owns nothing either — - // everything on that form came out of the database. - autoFilledRef.current = new Set( - !editingPlace && prefillCoords - ? (['name', 'address', 'lat', 'lng', 'website', 'phone', 'osm_id'] as ResultField[]).filter( - (field) => !!prefillCoords[field as keyof typeof prefillCoords], - ) - : [], - ) - setPendingFiles([]) - setDuplicateWarning(null) + let next: PlaceFormData + if (editingPlace) next = placeEditForm(editingPlace, findVisit(storedAssignments, editingAssignmentId)) + else if (prefillCoords) next = prefillForm(prefillCoords) + else next = DEFAULT_FORM + openForm(next, editingPlace, prefillCoords) setDeleteArmed(false) // storedAssignments is a fresh map each load, so it is read at open time only. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -210,12 +130,6 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh // while it still fits inside one region. const { box: locationBias } = useLocationBias() - const handleChange = (field: keyof PlaceFormData, value: string) => { - // Typed by hand, so the next pick must leave it alone. - autoFilledRef.current.delete(field as ResultField) - setForm(prev => ({ ...prev, [field]: value })) - } - // Same fix as the desktop dialog, same helper. `?? prev.X` cannot tell a // value the user typed from one the previous pick wrote, so searching an // airport and then a station left the airport's website in the field. @@ -248,37 +162,16 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh } const handleCoordPaste = (e: ClipboardEvent) => { - const text = e.clipboardData.getData('text').trim() - // Same grammar as before, spelled without the overlapping quantifiers pasted text - // could make backtrack: the separator is either a comma/semicolon with optional - // padding or pure whitespace, and a decimal is digits with an optional ".digits". - const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/) - if (match) { + const pair = parseCoordinatePair(e.clipboardData.getData('text')) + if (pair) { e.preventDefault() - setForm(prev => ({ ...prev, lat: match[1], lng: match[2] })) - } - } - - // Clipboard images/PDFs from any focused field become pending attachments. - const handlePaste = (e: ClipboardEvent) => { - if (!canUploadFiles) return - for (const item of Array.from(e.clipboardData?.items || [])) { - if (item.type.startsWith('image/') || item.type === 'application/pdf') { - e.preventDefault() - const file = item.getAsFile() - if (file) setPendingFiles(prev => [...prev, file]) - return - } + setForm(prev => ({ ...prev, lat: pair[0], lng: pair[1] })) } } // End before start blocks the save — tied to the values, not to which entry // point opened the sheet. - const hasTimeError = Boolean( - form.place_time && form.end_time && - form.place_time.length >= 5 && form.end_time.length >= 5 && - form.end_time <= form.place_time, - ) + const hasTimeError = endsBeforeStart(form.place_time, form.end_time) const handleSubmit = async () => { if (!form.name.trim()) { @@ -287,32 +180,12 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh } // #1152: first save of a new place warns on likely duplicates; a second // tap with the warning showing is the explicit "add anyway". - if (!sheetPlace && !duplicateWarning) { - const dup = findDuplicateName(form, places) - if (dup) { - setDuplicateWarning(dup) - toast.warning(t('places.duplicateExists', { name: dup })) - return - } - } + if (!sheetPlace && !duplicateWarning && warnIfDuplicate(places)) return const withExpense = expenseIntentRef.current expenseIntentRef.current = false setIsSaving(true) try { - const payload = { - ...form, - lat: form.lat ? Number.parseFloat(form.lat) : null, - lng: form.lng ? Number.parseFloat(form.lng) : null, - category_id: form.category_id || null, - _pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined, - } - // #2163: the per-assignment note only travels when an assignment is in - // context AND the value actually changed — same dirty-check as the - // desktop form, so an untouched note never produces a PUT. - if (!ctxAssignment || (form.assignment_notes ?? '') === (ctxAssignment.notes ?? '')) { - delete payload.assignment_notes - } - const saved = await handleSavePlace(payload) + const saved = await handleSavePlace(placeFormPayload(form, pendingFiles, ctxAssignment)) const savedId = saved?.id ?? sheetPlace?.id ?? null if (withExpense && savedId) { onOpenExpense({ prefill: { placeId: savedId, name: form.name.trim(), category: 'activities' } }) @@ -371,8 +244,8 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh setForm(prev => ({ ...prev, image_url: url ?? undefined }))} - onAdoptDescription={(text) => setForm(prev => ({ ...prev, description: text }))} + onPickImage={pickImage} + onAdoptDescription={adoptDescription} hasDescription={!!form.description.trim()} language={language} timeFormat={timeFormat} @@ -479,8 +352,8 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh setPendingFiles(prev => [...prev, ...files])} - onRemove={idx => setPendingFiles(prev => prev.filter((_, i) => i !== idx))} + onAdd={addFiles} + onRemove={removeFile} /> )} diff --git a/client/src/mobile/screens/trip/sheets/PlCategoryPicker.tsx b/client/src/mobile/screens/trip/sheets/PlCategoryPicker.tsx index f50fd0ae8..563897391 100644 --- a/client/src/mobile/screens/trip/sheets/PlCategoryPicker.tsx +++ b/client/src/mobile/screens/trip/sheets/PlCategoryPicker.tsx @@ -1,8 +1,7 @@ -import { useState } from 'react' import { Ban, Check, Plus } from 'lucide-react' import { getCategoryIcon } from '../../../../components/shared/categoryIcons' +import { useNewCategory } from '../../../../components/Planner/usePlaceForm' import { FIELD_CLS } from './PlSheetChrome' -import type { Category } from '../../../../types' import type { TripPlanner } from '../MTripShell' interface PlCategoryPickerProps { @@ -22,24 +21,9 @@ const PILL_BASE = */ export default function PlCategoryPicker({ planner, value, onChange }: PlCategoryPickerProps) { const { t, toast, categories, tripActions } = planner - const [creating, setCreating] = useState(false) - const [newName, setNewName] = useState('') - const [saving, setSaving] = useState(false) - - const handleCreate = async () => { - if (!newName.trim() || saving) return - setSaving(true) - try { - const created: Category = await tripActions.addCategory({ name: newName.trim(), color: '#6366f1', icon: 'MapPin' }) - onChange(String(created.id)) - setNewName('') - setCreating(false) - } catch { - toast.error(t('places.categoryCreateError')) - } finally { - setSaving(false) - } - } + const { + open: creating, setOpen: setCreating, name: newName, setName: setNewName, saving, submit: handleCreate, + } = useNewCategory({ variant: 'sheet', create: tripActions.addCategory, onCreated: onChange, t, toast }) return (
diff --git a/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx b/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx index 5cc733bc7..e6486f62a 100644 --- a/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx +++ b/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx @@ -1,17 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from 'react' import { Loader2, RotateCcw, Search } from 'lucide-react' -import { mapsApi } from '../../../../api/client' -import { useAuthStore } from '../../../../store/authStore' -import { corePickRank, offersGoogleRetry, selectGoogleHoldsSlot } from '../../../../utils/placeSource' -import { recordPlacePick } from '../../../../api/placeShadow' -import { PlacesSession } from '../../../../utils/placesSession' -import { isMapUrl } from '../../../../components/Planner/PlaceFormModal.helpers' -import { getApiErrorMessage } from '../../../../utils/apiError' -import { pointFromBox } from '../../../../hooks/useLocationBias' -import { usePlaceSuggestions } from '../../../../hooks/usePlaceSuggestions' +import { offersGoogleRetry } from '../../../../utils/placeSource' +import { usePlaceSearch, type MapsPlace } from '../../../../components/Planner/usePlaceSearch' import { FIELD_CLS } from './PlSheetChrome' import type { TripPlanner } from '../MTripShell' -import { usePlaceLanguage } from '../../../../hooks/usePlaceLanguage' /** Fields a search pick can contribute to the place form. */ export interface PlSearchPick { @@ -34,20 +25,6 @@ export interface PlSearchPick { details?: MapsPlace } -interface Suggestion { - placeId: string - mainText: string - secondaryText: string - /** Which of the two indexes this row came from, when the list is both. */ - source?: string - lat?: number - lng?: number - /** Only on a plugin's row: its whole place, since no details lookup knows a plugin id. */ - place?: MapsPlace -} - -type MapsPlace = Record - /** The same mark the desktop form shows, in the sheet's own tokens. */ function SourceMark({ label }: { label: string | null }) { if (!label) return null @@ -67,9 +44,6 @@ interface PlPlaceSearchProps { onResolvingChange?: (resolving: boolean) => void } -/** "48.8566, 2.3522" (also ; or whitespace separated) → direct coordinates. */ -const COORD_RE = /^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/ - function placeToPick(place: MapsPlace): PlSearchPick { const s = (v: unknown) => (v == null ? undefined : String(v)) return { @@ -90,206 +64,31 @@ function placeToPick(place: MapsPlace): PlSearchPick { /** * Search row of the place form: Google/OSM text search biased on the trip * centre, autocomplete dropdown, plus Google-Maps-URL and "lat, lng" paste - * detection — the mobile counterpart of PlaceFormModal's search block. + * detection — the mobile counterpart of PlaceFormModal's search block, on the + * same search logic (usePlaceSearch). */ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvingChange }: PlPlaceSearchProps) { const { t, toast } = planner - // Place names in the language the user picked for them, the app's otherwise (#1799). - const language = usePlaceLanguage() - const [query, setQuery] = useState('') - const [results, setResults] = useState([]) - const [suggestions, setSuggestions] = useState([]) - const [searching, setSearching] = useState(false) - const debounceRef = useRef | null>(null) - const abortRef = useRef(null) - // One Google billing session per search (see utils/placesSession). - const placesSessionRef = useRef(new PlacesSession()) - // What produced the list on screen, for the shadow log. Refs, because a pick - // has to read the values that belonged to that list. Mirrors PlaceFormModal. - const searchMetaRef = useRef<{ query: string; source: string } | null>(null) - const acMetaRef = useRef<{ query: string; source: string } | null>(null) - // The name the whole list carries, for rows that do not name their own index. - const [acSource, setAcSource] = useState('') - // What answered the last full search, for the line that offers Google instead. - const [searchSource, setSearchSource] = useState('') - const googleAnswers = useAuthStore(selectGoogleHoldsSlot) - const { autocomplete, sourceLabel } = usePlaceSuggestions() - - const setResolving = useCallback( - (v: boolean) => { - setSearching(v) - onResolvingChange?.(v) - }, - [onResolvingChange], - ) - - const fetchSuggestions = useCallback( - async (input: string) => { - abortRef.current?.abort() - const controller = new AbortController() - abortRef.current = controller - try { - const result = await autocomplete(input, language, locationBias, controller.signal, placesSessionRef.current.current()) - acMetaRef.current = { query: input, source: result.source || 'unknown' } - setAcSource(result.source || '') - setSuggestions(result.suggestions || []) - } catch (err: unknown) { - // Superseded request — axios rejects an aborted call with CanceledError. - if (err instanceof Error && err.name === 'CanceledError') return - setSuggestions([]) - } - }, - [autocomplete, language, locationBias], - ) - - // Debounced autocomplete — URLs and coordinate pastes go to the search button. - useEffect(() => { - if (debounceRef.current) clearTimeout(debounceRef.current) - const trimmed = query.trim() - if (trimmed.length < 2 || isMapUrl(trimmed) || COORD_RE.test(trimmed)) { - // A list still on its way belongs to a query that is gone. - abortRef.current?.abort() - setSuggestions([]) - return - } - debounceRef.current = setTimeout(() => fetchSuggestions(trimmed), 300) - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current) - } - }, [query, fetchSuggestions]) - - /** - * `pick` is present only when the place came out of a ranked list, so a - * coordinate paste or a resolved Google URL contributes no row. - */ - const applyPlace = (place: MapsPlace, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => { - onPick(placeToPick(place)) - if (pick) { - const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current - const lat = Number(place.lat) - const lng = Number(place.lng) - if (meta && Number.isFinite(lat) && Number.isFinite(lng)) { - recordPlacePick({ - query: meta.query, - lang: language, - biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined, - biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined, - source: `${pick.mode}:${meta.source}`, - liveRank: pick.rank, - liveCount: pick.count, - pickedName: String(place.name ?? ''), - pickedLat: lat, - pickedLng: lng, - pickedPlaceId: (place.google_place_id as string) || (place.amap_poi_id as string) || (place.osm_id as string) || null, - }) - } - } - setResults([]) - setSuggestions([]) - setQuery('') - } - - const handleSearch = async (provider?: 'google') => { - // The retry sends the query the list came from, as the desktop form does: - // the list stays on screen while the field is edited or cleared. - const trimmed = provider ? (searchMetaRef.current?.query ?? '') : query.trim() - if (!trimmed) return - setSuggestions([]) - - // "lat, lng" paste → straight to coordinates, no lookup needed. - const coords = trimmed.match(COORD_RE) - if (coords) { - onPick({ lat: coords[1], lng: coords[2] }) - setQuery('') - return - } - - setResolving(true) - try { - if (!provider && isMapUrl(trimmed)) { - const resolved = await mapsApi.resolveUrl(trimmed) - if (resolved.lat && resolved.lng) { - onPick({ - name: resolved.name || undefined, - address: resolved.address || undefined, - lat: String(resolved.lat), - lng: String(resolved.lng), - google_ftid: resolved.google_ftid || undefined, - }) - setQuery('') - toast.success(t('places.urlResolved')) - return - } - } - // Derselbe Hinweis, den die Vervollstaendigung schon bekommt: die Suche - // braucht ihn genauso, nur als Punkt statt als Kasten. - const result = await mapsApi.search(trimmed, language, pointFromBox(locationBias), provider) - searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' } - setResults(result.places || []) - setSearchSource(result.source || '') - } catch (err: unknown) { - toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) - } finally { - setResolving(false) - placesSessionRef.current.end() - } - } - - const handleSelectSuggestion = async (suggestion: Suggestion) => { - // Read before the list is cleared: this is the rank the user saw. - const acPick = corePickRank(suggestions, suggestion) - abortRef.current?.abort() - setSuggestions([]) - const previousQuery = query - setQuery('') - onPick({ name: suggestion.mainText }) - setResolving(true) - try { - // Details are a fragile second hop (kill-switch, Overpass load) — fall - // back to the text-search path so suggestions never dead-end. (#1192) - let place: MapsPlace | null = suggestion.place ?? null - if (!place) { - try { - // Spends the session the suggestions opened. - const result = await mapsApi.details(suggestion.placeId, language, placesSessionRef.current.peek()) - if (result.place && result.place.lat != null && result.place.lng != null) place = result.place - } catch { - // fall through to text search - } - } - if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) { - // The layer's second line is the local name, not an address, so joining - // the two makes a query nobody typed — and its first answer would be - // silently taken as the place the user picked. The row already knows - // where it is. - place = { - name: suggestion.mainText, - address: '', - lat: suggestion.lat, - lng: suggestion.lng, - osm_id: suggestion.placeId, - source: 'openstreetmap', - } - } - if (!place) { - const fullQuery = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ') - const search = await mapsApi.search(fullQuery, language, pointFromBox(locationBias)) - place = (search.places?.[0] as MapsPlace | undefined) ?? null - } - if (place) { - applyPlace(place, acPick && { mode: 'autocomplete', ...acPick }) - } else { - setQuery(previousQuery) - toast.error(t('places.mapsSearchError')) - } - } catch (err: unknown) { - setQuery(previousQuery) - toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) - } finally { - setResolving(false) - placesSessionRef.current.end() - } - } + const { + query, setQuery, results, suggestions, setSuggestions, acSource, searchSource, searching, googleAnswers, + sourceLabel, runSearch: handleSearch, pickPlace: applyPlace, selectSuggestion: handleSelectSuggestion, + } = usePlaceSearch({ + variant: 'sheet', + locationBias, + t, + toast, + onPlace: place => onPick(placeToPick(place)), + onSuggestionName: name => onPick({ name }), + onMapLink: resolved => onPick({ + name: resolved.name || undefined, + address: resolved.address || undefined, + lat: String(resolved.lat), + lng: String(resolved.lng), + google_ftid: resolved.google_ftid || undefined, + }), + onCoordinates: (lat, lng) => onPick({ lat, lng }), + onResolvingChange, + }) return (
diff --git a/client/src/mobile/screens/trip/sheets/PlTimeFields.tsx b/client/src/mobile/screens/trip/sheets/PlTimeFields.tsx index 5f50179a7..6e63f8b26 100644 --- a/client/src/mobile/screens/trip/sheets/PlTimeFields.tsx +++ b/client/src/mobile/screens/trip/sheets/PlTimeFields.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { AlertTriangle } from 'lucide-react' import { Eyebrow } from './PlSheetChrome' import CustomTimePicker from '../../../../components/shared/CustomTimePicker' +import { timeCollisions } from '../../../../components/Planner/PlaceFormModal.helpers' import type { Assignment } from '../../../../types' import type { TripPlanner } from '../MTripShell' @@ -33,23 +34,11 @@ export default function PlTimeFields({ // The plan tab reads the day as Days does, and there End stays a plain label. const endIsLeave = planner.activeTab === 'roadtrip' - const collisions = useMemo(() => { - if (!startTime || startTime.length < 5) return [] - const current = dayAssignments.find(a => a.id === assignmentId) - if (!current) return [] - const myEnd = endTime && endTime.length >= 5 ? endTime : null - return dayAssignments.filter(a => { - if (a.id === assignmentId || a.day_id !== current.day_id) return false - const otherStart = a.place?.place_time - const otherEnd = a.place?.end_time - if (!otherStart) return false - const s1 = startTime - const e1 = myEnd || startTime - const s2 = otherStart - const e2 = otherEnd || otherStart - return s1 < (e2 || '23:59') && s2 < (e1 || '23:59') && s1 !== e2 && s2 !== e1 - }) - }, [assignmentId, dayAssignments, startTime, endTime]) + // The other visits of the same day whose times overlap this one's, as the desktop form warns. + const collisions = useMemo( + () => timeCollisions(assignmentId, dayAssignments, startTime, endTime), + [assignmentId, dayAssignments, startTime, endTime], + ) return (