diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index 72e148c37..52ee10ab7 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -669,6 +669,12 @@ "desktop": ["src/components/Roadtrip/RoadtripStopPopup.tsx"], "mobile": ["src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx"] }, + { + "feature": "Road-trip Google Maps route import", + "hooks": ["src/components/Roadtrip/useGoogleRouteImport.ts"], + "desktop": ["src/components/Roadtrip/GoogleRouteImport.tsx"], + "mobile": ["src/mobile/screens/trip/roadtrip/MRtRouteImportSheet.tsx"] + }, { "feature": "Road-trip stay length draft", "hooks": ["src/components/Roadtrip/useStayDraft.ts"], diff --git a/client/src/components/Roadtrip/GoogleRouteImport.tsx b/client/src/components/Roadtrip/GoogleRouteImport.tsx index 2841a454e..adbf52a2d 100644 --- a/client/src/components/Roadtrip/GoogleRouteImport.tsx +++ b/client/src/components/Roadtrip/GoogleRouteImport.tsx @@ -1,60 +1,17 @@ -import { useEffect, useId, useRef, useState } from 'react' +import { useId } from 'react' import { MoreHorizontal, Link2 } from 'lucide-react' import { useTranslation } from '../../i18n/TranslationContext' -import { useTripStore } from '../../store/tripStore' -import { googleRouteRepo } from '../../repo/googleRouteRepo' -import { generateUUID } from '../../sync/mutationQueue' -import { getApiErrorMessage } from '../../types' -import type { GoogleRoutePreview } from '@trek/shared' import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import CustomSelect from '../shared/CustomSelect' import { ContextMenu, useContextMenu } from '../shared/ContextMenu' import { Tooltip } from '../shared/Tooltip' +import { useGoogleRouteImport } from './useGoogleRouteImport' export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; dayId?: number }) { const { t } = useTranslation() - const days = useTripStore(s => s.days) - const [open, setOpen] = useState(false) - const [url, setUrl] = useState('') - const [preview, setPreview] = useState(null) - const [target, setTarget] = useState('') - const [busy, setBusy] = useState(false) - const [failure, setFailure] = useState('') - const [saved, setSaved] = useState(false) - const controller = useRef(null) - const requestId = useRef('') + const { days, open, url, setUrl, preview, target, pickDay, busy, failure, saved, show, close, canSubmit, submit } = useGoogleRouteImport(tripId, dayId) const menu = useContextMenu() const labelId = useId() - useEffect(() => () => controller.current?.abort(), []) - const inspect = async () => { - controller.current?.abort() - const abort = new AbortController() - controller.current = abort - setBusy(true); setFailure(''); setPreview(null) - try { const parsed = await googleRouteRepo.preview(url, abort.signal); if (!abort.signal.aborted) setPreview(parsed) } - catch (error) { if (!abort.signal.aborted) setFailure(getApiErrorMessage(error, t('common.error'))) } - finally { if (!abort.signal.aborted) setBusy(false) } - } - const save = async () => { - if (!preview || !target) return - setBusy(true); setFailure('') - try { - const stops = preview.stops.filter(s => s.lat != null && s.lng != null).map(s => ({ name: s.name, lat: s.lat!, lng: s.lng! })) - await googleRouteRepo.append(tripId, { dayId: Number(target), stops }, requestId.current) - setSaved(true) - await useTripStore.getState().loadTrip(tripId) - setOpen(false) - } catch (error) { setFailure(getApiErrorMessage(error, t('common.error'))) } - finally { setBusy(false) } - } - const show = () => { - setUrl(''); setPreview(null); setFailure(''); setSaved(false) - setTarget(String(dayId ?? days[0]?.id ?? '')) - requestId.current = generateUUID(); setOpen(true) - } - // Every way out is held while a preview or an import is in flight, as before. - const close = () => { if (!busy) setOpen(false) } - const resolved = preview?.stops.filter(s => s.lat != null && s.lng != null) ?? [] return <> + + +
+ } + title={{t('roadtrip.import.title')}} + sub={t('roadtrip.import.note')} + subWrap + onClose={close} + closeLabel={t('common.close')} + /> +
+ +
+ {!preview ? ( + setUrl(e.target.value)} + aria-label="Google Maps URL" + placeholder="https://maps.app.goo.gl/…" + className={`${FIELD_CLS} disabled:opacity-50`} + /> + ) : ( + <> + {t('roadtrip.import.day')} + +
    + {preview.stops.map((stop, index) => { + const unresolved = stop.lat == null || stop.lng == null; + return ( +
  1. + + {index + 1} + + + {stop.name} + {unresolved && ( + {t('roadtrip.import.unresolved')} + )} + +
  2. + ); + })} +
+ + )} + {failure && ( +

+ {saved ? `${t('common.saved')}: ` : ''} + {failure} +

+ )} +
+ +
+ + +
+
+ + ); +} diff --git a/client/tests/unit/mobile/trip/MRtRouteImportSheet.test.tsx b/client/tests/unit/mobile/trip/MRtRouteImportSheet.test.tsx new file mode 100644 index 000000000..430dd6e5a --- /dev/null +++ b/client/tests/unit/mobile/trip/MRtRouteImportSheet.test.tsx @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _pinSizeClass, type SizeClass } from '../../../../src/hooks/sizeClass'; +import MRtRouteImport from '../../../../src/mobile/screens/trip/roadtrip/MRtRouteImportSheet'; +import { googleRouteRepo } from '../../../../src/repo/googleRouteRepo'; +import { useTripStore } from '../../../../src/store/tripStore'; +import { fireEvent, render, screen, waitFor } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; + +// FE-MOB-RTIMPORT-001 to FE-MOB-RTIMPORT-005 +// +// A Google Maps route appended to a day, as the stage card's offer on an iPad or +// an unfolded Duo, over the same useGoogleRouteImport as the desktop dialog. + +vi.mock('../../../../src/repo/googleRouteRepo', () => ({ googleRouteRepo: { preview: vi.fn(), append: vi.fn() } })); + +const loadTrip = vi.fn(async () => {}); + +function renderImport(size: SizeClass = 'wide', canEdit = true) { + _pinSizeClass(size); + return render(); +} + +async function previewRoute() { + fireEvent.click(screen.getByRole('button', { name: 'Import Google Maps route' })); + fireEvent.change(screen.getByRole('textbox', { name: 'Google Maps URL' }), { + target: { value: 'https://google.com/maps/dir/A/B' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Preview' })); + await screen.findByText('Munich'); +} + +describe('MRtRouteImport', () => { + beforeEach(() => { + resetAllStores(); + vi.clearAllMocks(); + seedStore(useTripStore, { + days: [ + { id: 3, day_number: 1, title: 'First day' }, + { id: 4, day_number: 2, title: null }, + ] as never, + loadTrip, + }); + }); + + afterEach(() => { + _pinSizeClass(null); + }); + + it('FE-MOB-RTIMPORT-001: is absent on the phone and for a reader', () => { + const phone = renderImport('compact'); + expect(phone.container).toBeEmptyDOMElement(); + phone.unmount(); + const reader = renderImport('split', false); + expect(reader.container).toBeEmptyDOMElement(); + }); + + it('FE-MOB-RTIMPORT-002: previews the stops in order and says which did not resolve', async () => { + vi.mocked(googleRouteRepo.preview).mockResolvedValue({ + stops: [ + { name: 'Munich', lat: 48, lng: 11 }, + { name: 'Unknown', lat: null, lng: null }, + { name: 'Rome', lat: 41, lng: 12 }, + ], + }); + renderImport(); + await previewRoute(); + const items = screen.getAllByRole('listitem').map((item) => item.textContent); + expect(items).toEqual([ + expect.stringContaining('Munich'), + expect.stringContaining('Unknown'), + expect.stringContaining('Rome'), + ]); + expect(screen.getByText('Location unresolved; this stop will be skipped.')).toBeInTheDocument(); + expect(googleRouteRepo.append).not.toHaveBeenCalled(); + }); + + it('FE-MOB-RTIMPORT-003: appends only the resolved stops to the day picked', async () => { + vi.mocked(googleRouteRepo.preview).mockResolvedValue({ + stops: [ + { name: 'Munich', lat: 48, lng: 11 }, + { name: 'Unknown', lat: null, lng: null }, + { name: 'Rome', lat: 41, lng: 12 }, + ], + }); + vi.mocked(googleRouteRepo.append).mockResolvedValue(undefined); + renderImport(); + await previewRoute(); + fireEvent.change(screen.getByRole('combobox', { name: 'Day' }), { target: { value: '4' } }); + expect(screen.getByRole('option', { name: 'Day 2' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Import' })); + await waitFor(() => expect(loadTrip).toHaveBeenCalledWith(1)); + expect(googleRouteRepo.append).toHaveBeenCalledWith( + 1, + { + dayId: 4, + stops: [ + { name: 'Munich', lat: 48, lng: 11 }, + { name: 'Rome', lat: 41, lng: 12 }, + ], + }, + expect.any(String) + ); + }); + + it('FE-MOB-RTIMPORT-004: holds the import while fewer than two stops resolve', async () => { + vi.mocked(googleRouteRepo.preview).mockResolvedValue({ + stops: [ + { name: 'Munich', lat: 48, lng: 11 }, + { name: 'Unknown', lat: null, lng: null }, + ], + }); + renderImport(); + await previewRoute(); + expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled(); + }); + + it('FE-MOB-RTIMPORT-005: a link that cannot be read is an alert, and the preview waits for a link', async () => { + vi.mocked(googleRouteRepo.preview).mockRejectedValue(new Error('Not a Google Maps route')); + renderImport(); + fireEvent.click(screen.getByRole('button', { name: 'Import Google Maps route' })); + expect(screen.getByRole('button', { name: 'Preview' })).toBeDisabled(); + fireEvent.change(screen.getByRole('textbox', { name: 'Google Maps URL' }), { target: { value: 'nonsense' } }); + fireEvent.click(screen.getByRole('button', { name: 'Preview' })); + expect(await screen.findByRole('alert')).toBeInTheDocument(); + }); +});