mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
feat(mobile): a Google Maps route is imported into a road trip day on an iPad and an unfolded Duo
The desktop's import dialog keeps its markup and moves its logic into useGoogleRouteImport, unchanged: the link previewed, its stops in order with the unresolved ones named, and the resolved ones appended to the day picked, at least two, under one request id per opening and day. The large screens get the same flow as a sheet, offered on the stage card under the track offer to a member who may add places to days. The phone has no entry to it.
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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<GoogleRoutePreview | null>(null)
|
||||
const [target, setTarget] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
const controller = useRef<AbortController | null>(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 <>
|
||||
<Tooltip label={t('roadtrip.import.title')}>
|
||||
<button type="button" aria-label={t('roadtrip.import.title')} className="shrink-0 rounded-lg p-2 text-content-muted hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||
@@ -84,7 +41,7 @@ export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; d
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton disabled={busy} onClick={close}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" disabled={busy || saved || (!preview ? !url.trim() : resolved.length < 2 || !target)} onClick={() => void (preview ? save() : inspect())}>
|
||||
<DialogButton variant="primary" disabled={!canSubmit} onClick={submit}>
|
||||
{busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
@@ -94,7 +51,7 @@ export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; d
|
||||
className="w-full rounded-[10px] border border-edge bg-surface-input px-3 py-2.5 text-content outline-none placeholder:text-content-faint focus:ring-2 focus:ring-[color:var(--text-primary)] disabled:opacity-50"
|
||||
style={fs(13, 'body')} /> : <>
|
||||
<DialogSection label={t('roadtrip.import.day')}>
|
||||
<CustomSelect value={target} onChange={value => { setTarget(String(value)); requestId.current = generateUUID() }}
|
||||
<CustomSelect value={target} onChange={value => pickDay(String(value))}
|
||||
options={days.map(day => ({ value: String(day.id), label: day.title || `${t('roadtrip.import.day')} ${day.day_number}` }))} disabled={busy || saved} />
|
||||
</DialogSection>
|
||||
<ol className="m-0 flex max-h-64 list-none flex-col gap-2.5 overflow-auto rounded-[14px] border border-edge-faint bg-surface-secondary p-3.5">
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { GoogleRoutePreview } from '@trek/shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import { googleRouteRepo } from '../../repo/googleRouteRepo';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { generateUUID } from '../../sync/mutationQueue';
|
||||
import { getApiErrorMessage } from '../../types';
|
||||
|
||||
/**
|
||||
* A route shared from Google Maps, read and appended to a day of the trip: the logic
|
||||
* behind the desktop's import dialog (GoogleRouteImport) and the large screens' sheet
|
||||
* (MRtRouteImportSheet), so the two cannot drift.
|
||||
*
|
||||
* A link is previewed first, the stops it names in order, and only the ones that resolved
|
||||
* to a place are appended, at least two of them. One request id per opening and per day
|
||||
* picked, so a retried append lands once. Every way out is held while a preview or an
|
||||
* import is in flight.
|
||||
*/
|
||||
export function useGoogleRouteImport(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<GoogleRoutePreview | null>(null);
|
||||
const [target, setTarget] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failure, setFailure] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
const requestId = useRef('');
|
||||
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);
|
||||
};
|
||||
const close = () => {
|
||||
if (!busy) setOpen(false);
|
||||
};
|
||||
/** Another day is another append, so it gets a request id of its own. */
|
||||
const pickDay = (value: string) => {
|
||||
setTarget(value);
|
||||
requestId.current = generateUUID();
|
||||
};
|
||||
const resolved = preview?.stops.filter((s) => s.lat != null && s.lng != null) ?? [];
|
||||
const canSubmit = !busy && !saved && (!preview ? !!url.trim() : resolved.length >= 2 && !!target);
|
||||
/** The one button: the preview while there is none, the import once there is. */
|
||||
const submit = () => {
|
||||
void (preview ? save() : inspect());
|
||||
};
|
||||
|
||||
return { days, open, url, setUrl, preview, target, pickDay, busy, failure, saved, show, close, canSubmit, submit };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useMRtAlternatives } from './useMRtAlternatives'
|
||||
import MRtCorridorBar from './MRtCorridorBar'
|
||||
import MRtAlternativesBar from './MRtAlternativesBar'
|
||||
import { MRtTrackEntry } from './MRtTrackSheet'
|
||||
import MRtRouteImport from './MRtRouteImportSheet'
|
||||
import { RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
|
||||
import MBadge from '../../../components/MBadge'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
@@ -291,6 +292,11 @@ function RoadtripStagePage({ planner, shell, dayId: pageDayId, alts, chrome }: O
|
||||
</div>
|
||||
)}
|
||||
<MRtTrackEntry planner={planner} dayId={stage.dayId} />
|
||||
<MRtRouteImport
|
||||
tripId={planner.tripId}
|
||||
dayId={stage.dayId > 0 ? stage.dayId : undefined}
|
||||
canEdit={canEditPlaces && planner.can('day_edit', planner.trip)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{rt.upNext && <UpNext planner={planner} shell={shell} rt={rt} stageDayId={stage.dayId} onOpen={openStop} />}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Link2 } from 'lucide-react';
|
||||
import { useGoogleRouteImport } from '../../../../components/Roadtrip/useGoogleRouteImport';
|
||||
import { useIsWide } from '../../../../hooks/sizeClass';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import MSheet from '../../../components/MSheet';
|
||||
import { Eyebrow, SHEET_PRIMARY, SHEET_SECONDARY, TileHeader } from '../sheets/MTripSheetUi';
|
||||
import { FIELD_CLS } from '../sheets/PlSheetChrome';
|
||||
|
||||
const NOTE = 'font-geist text-caption leading-snug';
|
||||
|
||||
/**
|
||||
* A route shared from Google Maps, appended to a day, on an iPad or an unfolded Duo.
|
||||
*
|
||||
* The desktop's import dialog (GoogleRouteImport) as the stage card's second offer under the
|
||||
* track one, over the same useGoogleRouteImport: a link is previewed, its stops listed in
|
||||
* order with the ones that did not resolve said so, and the resolved ones appended to the
|
||||
* day picked. Absent on the phone, where the stage stays a plan read on the road, and for a
|
||||
* reader who may not add places to days, since the import does both.
|
||||
*/
|
||||
export default function MRtRouteImport({
|
||||
tripId,
|
||||
dayId,
|
||||
canEdit,
|
||||
}: {
|
||||
tripId: number;
|
||||
dayId?: number;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const large = useIsWide();
|
||||
const flow = useGoogleRouteImport(tripId, dayId);
|
||||
if (!large || !canEdit) return null;
|
||||
const { days, open, url, setUrl, preview, target, pickDay, busy, failure, saved, show, close, canSubmit, submit } =
|
||||
flow;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={show}
|
||||
className="mt-2 flex min-h-10 w-full items-center gap-2 rounded-[13px] bg-[color:var(--m-ic)] px-3 py-2 text-start"
|
||||
>
|
||||
<Link2 size={15} strokeWidth={1.9} className="flex-none text-m-muted" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-m-ink">
|
||||
{t('roadtrip.import.title')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<MSheet open={open} onClose={close} variant="bottom" material="glass" ariaLabel={t('roadtrip.import.title')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Link2 size={19} strokeWidth={1.8} />}
|
||||
title={<span className="truncate">{t('roadtrip.import.title')}</span>}
|
||||
sub={t('roadtrip.import.note')}
|
||||
subWrap
|
||||
onClose={close}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-3 pt-[14px]">
|
||||
{!preview ? (
|
||||
<input
|
||||
type="url"
|
||||
dir="ltr"
|
||||
value={url}
|
||||
disabled={busy}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
aria-label="Google Maps URL"
|
||||
placeholder="https://maps.app.goo.gl/…"
|
||||
className={`${FIELD_CLS} disabled:opacity-50`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Eyebrow className="mb-[5px]">{t('roadtrip.import.day')}</Eyebrow>
|
||||
<select
|
||||
value={target}
|
||||
disabled={busy || saved}
|
||||
onChange={(e) => pickDay(e.target.value)}
|
||||
aria-label={t('roadtrip.import.day')}
|
||||
className={`${FIELD_CLS} appearance-none`}
|
||||
>
|
||||
{days.map((day) => (
|
||||
<option key={day.id} value={String(day.id)}>
|
||||
{day.title || `${t('roadtrip.import.day')} ${day.day_number}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ol
|
||||
className="m-0 mt-3 flex list-none flex-col gap-2.5 rounded-[16px] bg-[color:var(--m-ic)] p-3.5"
|
||||
data-testid="rt-import-stops"
|
||||
>
|
||||
{preview.stops.map((stop, index) => {
|
||||
const unresolved = stop.lat == null || stop.lng == null;
|
||||
return (
|
||||
<li key={index} className="flex items-baseline gap-3">
|
||||
<span className="grid h-5 w-5 flex-none place-items-center rounded-full bg-[color:var(--m-card)] text-caption font-semibold tabular-nums text-m-muted">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 break-words text-body text-m-ink">
|
||||
<span dir="auto">{stop.name}</span>
|
||||
{unresolved && (
|
||||
<span className={`block ${NOTE} text-m-faint`}>{t('roadtrip.import.unresolved')}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
{failure && (
|
||||
<p role="alert" className={`mt-2.5 ${NOTE} text-danger`}>
|
||||
{saved ? `${t('common.saved')}: ` : ''}
|
||||
{failure}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none items-center justify-end gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<button type="button" disabled={busy} onClick={close} className={SHEET_SECONDARY}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" disabled={!canSubmit} onClick={submit} className={SHEET_PRIMARY}>
|
||||
{busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')}
|
||||
</button>
|
||||
</div>
|
||||
</MSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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(<MRtRouteImport tripId={1} dayId={3} canEdit={canEdit} />);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user