refactor(places): share the GPX, KML and KMZ file import between desktop and phone

usePlacesFileImport holds the GPX, KML and KMZ import that
FileImportModal and ImpFileStep each carried. The desktop keeps one
toast and one undo label and counts only the summary's skips. The phone
keeps a toast per format, the importFiles undo label for a mix and the
top level skipped fallback.
This commit is contained in:
Maurice
2026-10-08 20:56:22 +00:00
parent f2b6a14e9f
commit 4dc446424f
4 changed files with 706 additions and 344 deletions
+11 -200
View File
@@ -1,24 +1,15 @@
import React from 'react'
import { useState, useRef, useEffect, useId } from 'react'
import { useRef, useId } from 'react'
import { Check, FileDown, Loader2, Upload } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import ToggleSwitch from '../Settings/ToggleSwitch'
import {
DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs,
} from '../shared/DialogShell'
import { EditorField } from '../shared/dialogParts'
interface PlacesImportSummary {
totalPlacemarks: number
createdCount: number
skippedCount: number
warnings: string[]
errors: string[]
}
import { usePlacesFileImport } from './usePlacesFileImport'
interface FileImportModalProps {
isOpen: boolean
@@ -28,8 +19,6 @@ interface FileImportModalProps {
initialFile?: File | null
}
const MAX_FILE_BYTES = 10 * 1024 * 1024
/** One tickable kind of content in the file (waypoints, paths…). */
function TypeCheck({ checked, label, onToggle }: { checked: boolean; label: string; onToggle: () => void }) {
return (
@@ -51,193 +40,15 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
const loadTrip = useTripStore((s) => s.loadTrip)
const fileInputRef = useRef<HTMLInputElement>(null)
const titleId = useId()
const [files, setFiles] = useState<File[]>([])
const [isDragOver, setIsDragOver] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [summary, setSummary] = useState<PlacesImportSummary | null>(null)
const [gpxOpts, setGpxOpts] = useState({ waypoints: true, routes: true, tracks: true })
const [kmlOpts, setKmlOpts] = useState({ points: true, paths: true })
// The Google pass a list import offers, for the points of a file too (#2536).
const canEnrichImport = useAuthStore(s => s.hasMapsKey)
const [enrich, setEnrich] = useState(false)
const {
files, isDragOver, loading, error, summary, gpxOpts, toggleGpxOpt, kmlOpts, toggleKmlOpt,
canEnrich: canEnrichImport, enrich, setEnrich, handleInputChange, handleDragOver, handleDragLeave, handleDrop,
handleImport, close: handleClose, isGpx, isKml, gpxNoneSelected, kmlNoneSelected, canImport,
} = usePlacesFileImport({
tripId, t, toast, loadTrip, pushUndo, onDone: onClose, variant: 'dialog', open: isOpen, initialFile,
})
const enrichHintId = useId()
const validateFile = (f: File): string | null => {
const ext = f.name.toLowerCase().split('.').pop()
if (ext !== 'gpx' && ext !== 'kml' && ext !== 'kmz') {
return t('places.importFileUnsupported')
}
if (f.size > MAX_FILE_BYTES) {
return t('places.importFileTooLarge', { maxMb: 10 })
}
return null
}
const reset = () => {
setFiles([])
setIsDragOver(false)
setLoading(false)
setError('')
setSummary(null)
}
// When the modal opens, reset state and pre-load any file dropped from the sidebar.
useEffect(() => {
if (!isOpen) return
setIsDragOver(false)
setLoading(false)
setSummary(null)
if (initialFile) {
const err = validateFile(initialFile)
if (err) {
setFiles([])
setError(err)
} else {
setFiles([initialFile])
setError('')
}
} else {
setFiles([])
setError('')
}
// validateFile uses t() which is stable — intentionally omitted from deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, initialFile])
const handleClose = () => {
reset()
onClose()
}
const selectFiles = (incoming: File[]) => {
if (incoming.length === 0) return
const valid: File[] = []
let firstError: string | null = null
for (const f of incoming) {
const validationError = validateFile(f)
if (validationError) {
firstError = firstError ?? validationError
continue
}
valid.push(f)
}
if (valid.length === 0) {
setError(firstError ?? '')
setFiles([])
return
}
setFiles(valid)
setError(firstError ?? '')
setSummary(null)
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.files ? Array.from(e.target.files) : []
e.target.value = ''
if (list.length) selectFiles(list)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(true)
}
const handleDragLeave = (e: React.DragEvent) => {
if (e.target === e.currentTarget) setIsDragOver(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
const list = Array.from(e.dataTransfer.files)
if (list.length) selectFiles(list)
}
const handleImport = async () => {
if (files.length === 0 || loading) return
setLoading(true)
setError('')
setSummary(null)
let totalCreated = 0
let totalSkipped = 0
const createdIds: number[] = []
const errors: string[] = []
let mergedSummary: PlacesImportSummary | null = null
let importedGpx = false
let importedKml = false
for (const f of files) {
const ext = f.name.toLowerCase().split('.').pop()
try {
if (ext === 'gpx') {
importedGpx = true
const result = await placesApi.importGpx(tripId, f, { ...gpxOpts, enrich: enrich && canEnrichImport })
totalCreated += result.count ?? 0
totalSkipped += result.skipped ?? 0
if (result.places?.length > 0) createdIds.push(...result.places.map((p: { id: number }) => p.id))
} else {
importedKml = true
const result = await placesApi.importMapFile(tripId, f, { ...kmlOpts, enrich: enrich && canEnrichImport })
totalCreated += result.count ?? 0
if (result.places?.length > 0) createdIds.push(...result.places.map((p: { id: number }) => p.id))
const s = result.summary as PlacesImportSummary | undefined
if (s) {
mergedSummary = mergedSummary
? {
totalPlacemarks: mergedSummary.totalPlacemarks + s.totalPlacemarks,
createdCount: mergedSummary.createdCount + s.createdCount,
skippedCount: mergedSummary.skippedCount + s.skippedCount,
warnings: [...mergedSummary.warnings, ...(s.warnings ?? [])],
errors: [...mergedSummary.errors, ...(s.errors ?? [])],
}
: s
totalSkipped += s.skippedCount ?? 0
}
}
} catch (err: any) {
const message = err?.response?.data?.error || t('places.importFileError')
errors.push(files.length > 1 ? `${f.name}: ${message}` : message)
}
}
await loadTrip(tripId)
if (createdIds.length > 0) {
pushUndo?.(importedGpx && !importedKml ? t('undo.importGpx') : t('undo.importKeyholeMarkup'), async () => {
try { await placesApi.bulkDelete(tripId, createdIds) } catch {}
await loadTrip(tripId)
})
}
if (totalCreated > 0) {
const key = importedKml && !importedGpx ? 'places.kmlKmzImported' : 'places.gpxImported'
toast.success(t(key, { count: totalCreated }))
} else if (totalSkipped > 0 && errors.length === 0) {
toast.warning(t('places.importAllSkipped'))
}
if (mergedSummary) setSummary(mergedSummary)
if (errors.length > 0) {
setError(errors.join('\n'))
toast.error(errors[0])
}
setLoading(false)
// Close once everything succeeded and there's no KML summary left to surface.
if (errors.length === 0 && !mergedSummary) handleClose()
}
const exts = files.map(f => f.name.toLowerCase().split('.').pop() ?? '')
const isGpx = exts.includes('gpx')
const isKml = exts.some(e => e === 'kml' || e === 'kmz')
const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks
const kmlNoneSelected = isKml && !kmlOpts.points && !kmlOpts.paths
const canImport = files.length > 0 && !loading && !gpxNoneSelected && !kmlNoneSelected
if (!isOpen) return null
let zoneText: React.ReactNode
@@ -308,7 +119,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
<TypeCheck
key={key}
checked={gpxOpts[key]}
onToggle={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))}
onToggle={() => toggleGpxOpt(key)}
label={t(key === 'waypoints' ? 'places.gpxImportWaypoints' : key === 'routes' ? 'places.gpxImportRoutes' : 'places.gpxImportTracks')}
/>
))}
@@ -324,7 +135,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
<TypeCheck
key={key}
checked={kmlOpts[key]}
onToggle={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))}
onToggle={() => toggleKmlOpt(key)}
label={t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')}
/>
))}
@@ -0,0 +1,362 @@
// FE-PLANNER-FILEIMPHOOK-001 to FE-PLANNER-FILEIMPHOOK-018: the GPX/KML/KMZ import logic
// behind the desktop dialog (variant 'dialog') and the phone's import step ('sheet').
import { act, renderHook } from '@testing-library/react';
import type React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { placesApi } from '../../api/client';
import { useAuthStore } from '../../store/authStore';
import { usePlacesFileImport, type PlacesFileImportOptions } from './usePlacesFileImport';
const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key}:${Object.values(params).join(',')}` : key;
const gpx = (name = 'route.gpx') => new File(['<gpx/>'], name, { type: 'application/gpx+xml' });
const kml = (name = 'places.kml') => new File(['<kml/>'], name, { type: 'application/vnd.google-earth.kml+xml' });
function oversized(name = 'big.gpx') {
const f = gpx(name);
Object.defineProperty(f, 'size', { value: 11 * 1024 * 1024 });
return f;
}
const summary = (over: Partial<{ total: number; created: number; skipped: number; warnings: string[] }> = {}) => ({
totalPlacemarks: over.total ?? 3,
createdCount: over.created ?? 2,
skippedCount: over.skipped ?? 1,
warnings: over.warnings ?? [],
errors: [],
});
type HookProps = Pick<PlacesFileImportOptions, 'open' | 'initialFile'>;
function setup(over: Partial<PlacesFileImportOptions> = {}) {
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
const loadTrip = vi.fn(async (_tripId: number) => undefined);
const pushUndo = vi.fn((_label: string, _undo: () => Promise<void> | void) => {});
const onDone = vi.fn();
const base: PlacesFileImportOptions = { tripId: 3, t, toast, loadTrip, pushUndo, onDone, variant: 'sheet', ...over };
const hook = renderHook((p: HookProps) => usePlacesFileImport({ ...base, ...p }), {
initialProps: { open: over.open, initialFile: over.initialFile },
});
return { ...hook, toast, loadTrip, pushUndo, onDone };
}
function choose(result: { current: ReturnType<typeof usePlacesFileImport> }, files: File[]) {
const target = { files, value: 'C:\\fakepath\\x' };
act(() => result.current.handleInputChange({ target } as unknown as React.ChangeEvent<HTMLInputElement>));
return target;
}
async function runImport(result: { current: ReturnType<typeof usePlacesFileImport> }) {
await act(async () => {
await result.current.handleImport();
});
}
beforeEach(() => {
resetAllStores();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('usePlacesFileImport: choosing files', () => {
it('FE-PLANNER-FILEIMPHOOK-001: keeps the valid files, reports the first problem and clears the input', () => {
const { result } = setup();
const target = choose(result, [gpx('a.gpx'), new File(['x'], 'notes.txt'), oversized(), kml('b.kmz')]);
expect(target.value).toBe('');
expect(result.current.files.map((f) => f.name)).toEqual(['a.gpx', 'b.kmz']);
expect(result.current.error).toBe('places.importFileUnsupported');
expect(result.current.isGpx).toBe(true);
expect(result.current.isKml).toBe(true);
expect(result.current.canImport).toBe(true);
});
it('FE-PLANNER-FILEIMPHOOK-002: an oversized file is refused with the size limit, an empty pick changes nothing', () => {
const { result } = setup();
choose(result, [oversized()]);
expect(result.current.files).toEqual([]);
expect(result.current.error).toBe('places.importFileTooLarge:10');
choose(result, []);
expect(result.current.error).toBe('places.importFileTooLarge:10');
});
it('FE-PLANNER-FILEIMPHOOK-003: unticking every kind of content blocks the import, re-ticking lifts it', () => {
const { result } = setup();
choose(result, [gpx(), kml()]);
act(() => {
result.current.toggleGpxOpt('waypoints');
result.current.toggleGpxOpt('routes');
result.current.toggleGpxOpt('tracks');
});
expect(result.current.gpxOpts).toEqual({ waypoints: false, routes: false, tracks: false });
expect(result.current.gpxNoneSelected).toBe(true);
expect(result.current.canImport).toBe(false);
act(() => result.current.toggleGpxOpt('routes'));
expect(result.current.canImport).toBe(true);
act(() => {
result.current.toggleKmlOpt('points');
result.current.toggleKmlOpt('paths');
});
expect(result.current.kmlNoneSelected).toBe(true);
expect(result.current.canImport).toBe(false);
});
it('FE-PLANNER-FILEIMPHOOK-004: dragging lights the zone, leaving it from a child does not, a drop selects', () => {
const { result } = setup({ variant: 'dialog' });
const preventDefault = vi.fn();
act(() => result.current.handleDragOver({ preventDefault } as unknown as React.DragEvent));
expect(result.current.isDragOver).toBe(true);
const zone = {};
act(() => result.current.handleDragLeave({ target: {}, currentTarget: zone } as unknown as React.DragEvent));
expect(result.current.isDragOver).toBe(true);
act(() => result.current.handleDragLeave({ target: zone, currentTarget: zone } as unknown as React.DragEvent));
expect(result.current.isDragOver).toBe(false);
act(() => result.current.handleDragOver({ preventDefault } as unknown as React.DragEvent));
act(() =>
result.current.handleDrop({
preventDefault,
dataTransfer: { files: [gpx('dropped.gpx')] },
} as unknown as React.DragEvent)
);
expect(result.current.isDragOver).toBe(false);
expect(result.current.files.map((f) => f.name)).toEqual(['dropped.gpx']);
expect(preventDefault).toHaveBeenCalledTimes(3);
});
});
describe('usePlacesFileImport: the desktop dialog opening', () => {
it('FE-PLANNER-FILEIMPHOOK-005: an opening takes the dropped file, or refuses it, and starts clean otherwise', () => {
const { result, rerender } = setup({ variant: 'dialog', open: false, initialFile: gpx('dropped.gpx') });
expect(result.current.files).toEqual([]);
rerender({ open: true, initialFile: gpx('dropped.gpx') });
expect(result.current.files.map((f) => f.name)).toEqual(['dropped.gpx']);
expect(result.current.error).toBe('');
rerender({ open: true, initialFile: new File(['x'], 'photo.jpg') });
expect(result.current.files).toEqual([]);
expect(result.current.error).toBe('places.importFileUnsupported');
rerender({ open: true, initialFile: null });
expect(result.current.files).toEqual([]);
expect(result.current.error).toBe('');
});
it('FE-PLANNER-FILEIMPHOOK-006: without an opening flag (the phone) nothing is preloaded', () => {
const { result } = setup({ variant: 'sheet', initialFile: gpx() });
expect(result.current.files).toEqual([]);
});
it('FE-PLANNER-FILEIMPHOOK-007: close resets the dialog and calls onDone', () => {
const { result, onDone } = setup({ variant: 'dialog' });
choose(result, [gpx()]);
act(() => result.current.close());
expect(result.current.files).toEqual([]);
expect(onDone).toHaveBeenCalledTimes(1);
});
});
describe('usePlacesFileImport: importing', () => {
it('FE-PLANNER-FILEIMPHOOK-008: a GPX import sends the ticked kinds, reloads, toasts, offers the undo and finishes', async () => {
const importGpx = vi
.spyOn(placesApi, 'importGpx')
.mockResolvedValue({ count: 2, skipped: 0, places: [{ id: 21 }, { id: 22 }] });
const bulkDelete = vi.spyOn(placesApi, 'bulkDelete').mockRejectedValue(new Error('gone'));
const { result, toast, loadTrip, pushUndo, onDone } = setup();
const file = gpx();
choose(result, [file]);
act(() => result.current.toggleGpxOpt('routes'));
await runImport(result);
expect(importGpx).toHaveBeenCalledWith(3, file, { waypoints: true, routes: false, tracks: true, enrich: false });
expect(loadTrip).toHaveBeenCalledWith(3);
expect(toast.success).toHaveBeenCalledWith('places.gpxImported:2');
expect(pushUndo).toHaveBeenCalledWith('undo.importGpx', expect.any(Function));
expect(onDone).toHaveBeenCalledTimes(1);
expect(result.current.loading).toBe(false);
await pushUndo.mock.calls[0][1]();
expect(bulkDelete).toHaveBeenCalledWith(3, [21, 22]);
expect(loadTrip).toHaveBeenCalledTimes(2);
});
it('FE-PLANNER-FILEIMPHOOK-009: enrichment goes out only when asked for and a maps key exists', async () => {
seedStore(useAuthStore, { hasMapsKey: true });
const importMapFile = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 1, places: [] });
const { result } = setup();
expect(result.current.canEnrich).toBe(true);
const file = kml();
choose(result, [file]);
act(() => result.current.setEnrich(true));
await runImport(result);
expect(importMapFile).toHaveBeenCalledWith(3, file, { points: true, paths: true, enrich: true });
});
it('FE-PLANNER-FILEIMPHOOK-010: the phone counts a mixed selection per format and names the undo for both', async () => {
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 2, places: [{ id: 31 }, { id: 32 }] });
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 1, skipped: 0, places: [{ id: 21 }] });
const bulkDelete = vi.spyOn(placesApi, 'bulkDelete').mockResolvedValue({ deleted: 3 });
const { result, toast, pushUndo, onDone } = setup({ variant: 'sheet' });
choose(result, [kml(), gpx()]);
await runImport(result);
expect(toast.success).toHaveBeenCalledWith('places.gpxImported:1');
expect(toast.success).toHaveBeenCalledWith('places.kmlKmzImported:2');
expect(pushUndo).toHaveBeenCalledWith('undo.importFiles', expect.any(Function));
await pushUndo.mock.calls[0][1]();
// GPX ids first, whatever order the files came in.
expect(bulkDelete).toHaveBeenCalledWith(3, [21, 31, 32]);
expect(onDone).toHaveBeenCalledTimes(1);
// The phone hands back without resetting: its sheet fades out with the files still shown.
expect(result.current.files).toHaveLength(2);
});
it('FE-PLANNER-FILEIMPHOOK-011: the dialog counts a mixed selection as one and resets before it closes', async () => {
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 2, places: [{ id: 31 }, { id: 32 }] });
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 1, skipped: 0, places: [{ id: 21 }] });
const bulkDelete = vi.spyOn(placesApi, 'bulkDelete').mockResolvedValue({ deleted: 3 });
const { result, toast, pushUndo, onDone } = setup({ variant: 'dialog' });
choose(result, [kml(), gpx()]);
await runImport(result);
expect(toast.success).toHaveBeenCalledTimes(1);
expect(toast.success).toHaveBeenCalledWith('places.gpxImported:3');
expect(pushUndo).toHaveBeenCalledWith('undo.importKeyholeMarkup', expect.any(Function));
await pushUndo.mock.calls[0][1]();
// In the order the files were sent.
expect(bulkDelete).toHaveBeenCalledWith(3, [31, 32, 21]);
expect(onDone).toHaveBeenCalledTimes(1);
expect(result.current.files).toEqual([]);
});
it('FE-PLANNER-FILEIMPHOOK-012: the dialog names a KML-only import by its own key', async () => {
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 4, places: [{ id: 1 }] });
const { result, toast, pushUndo } = setup({ variant: 'dialog' });
choose(result, [kml()]);
await runImport(result);
expect(toast.success).toHaveBeenCalledWith('places.kmlKmzImported:4');
expect(pushUndo).toHaveBeenCalledWith('undo.importKeyholeMarkup', expect.any(Function));
});
it('FE-PLANNER-FILEIMPHOOK-013: a KML summary is merged across files and keeps the import open', async () => {
vi.spyOn(placesApi, 'importMapFile')
.mockResolvedValueOnce({ count: 2, places: [], summary: summary({ warnings: ['a'] }) })
.mockResolvedValueOnce({
count: 1,
places: [],
summary: summary({ total: 2, created: 1, skipped: 1, warnings: ['b'] }),
});
const { result, onDone } = setup({ variant: 'dialog' });
choose(result, [kml('a.kml'), kml('b.kml')]);
await runImport(result);
expect(result.current.summary).toEqual({
totalPlacemarks: 5,
createdCount: 3,
skippedCount: 2,
warnings: ['a', 'b'],
errors: [],
});
expect(onDone).not.toHaveBeenCalled();
});
it('FE-PLANNER-FILEIMPHOOK-014: a KML answer without a summary counts its skips on the phone only', async () => {
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 0, skipped: 3, places: [] });
const sheet = setup({ variant: 'sheet' });
choose(sheet.result, [kml()]);
await runImport(sheet.result);
expect(sheet.toast.warning).toHaveBeenCalledWith('places.importAllSkipped');
sheet.unmount();
const dialog = setup({ variant: 'dialog' });
choose(dialog.result, [kml()]);
await runImport(dialog.result);
expect(dialog.toast.warning).not.toHaveBeenCalled();
expect(dialog.onDone).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-FILEIMPHOOK-015: an all-skipped GPX import warns on both and offers no undo', async () => {
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 0, skipped: 2, places: [] });
for (const variant of ['dialog', 'sheet'] as const) {
const { result, toast, pushUndo, unmount } = setup({ variant });
choose(result, [gpx()]);
await runImport(result);
expect(toast.warning).toHaveBeenCalledWith('places.importAllSkipped');
expect(toast.success).not.toHaveBeenCalled();
expect(pushUndo).not.toHaveBeenCalled();
unmount();
}
});
it('FE-PLANNER-FILEIMPHOOK-016: a failing file shows its error, prefixed by name when there are several, and stays open', async () => {
vi.spyOn(placesApi, 'importGpx').mockRejectedValue({ response: { data: { error: 'Broken GPX' } } });
vi.spyOn(placesApi, 'importMapFile').mockRejectedValue(new Error('network'));
const single = setup({ variant: 'dialog' });
choose(single.result, [gpx()]);
await runImport(single.result);
expect(single.result.current.error).toBe('Broken GPX');
expect(single.toast.error).toHaveBeenCalledWith('Broken GPX');
expect(single.onDone).not.toHaveBeenCalled();
expect(single.result.current.loading).toBe(false);
single.unmount();
const several = setup({ variant: 'sheet' });
choose(several.result, [gpx('a.gpx'), kml('b.kml')]);
await runImport(several.result);
expect(several.result.current.error).toBe('a.gpx: Broken GPX\nb.kml: places.importFileError');
expect(several.toast.error).toHaveBeenCalledWith('a.gpx: Broken GPX');
expect(several.toast.warning).not.toHaveBeenCalled();
});
it('FE-PLANNER-FILEIMPHOOK-017: a pick of nothing usable keeps the summary in the dialog and clears it on the phone', async () => {
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 2, places: [], summary: summary() });
for (const variant of ['dialog', 'sheet'] as const) {
const { result, unmount } = setup({ variant });
choose(result, [kml()]);
await runImport(result);
expect(result.current.summary).not.toBeNull();
choose(result, [new File(['x'], 'notes.txt')]);
expect(result.current.files).toEqual([]);
expect(result.current.error).toBe('places.importFileUnsupported');
if (variant === 'dialog') expect(result.current.summary).not.toBeNull();
else expect(result.current.summary).toBeNull();
unmount();
}
});
it('FE-PLANNER-FILEIMPHOOK-018: a second import while one runs, or one with no file, is refused', async () => {
let release: (value: unknown) => void = () => {};
const importGpx = vi.spyOn(placesApi, 'importGpx').mockReturnValue(
new Promise((resolve) => {
release = resolve;
})
);
const { result, loadTrip } = setup();
await runImport(result);
expect(importGpx).not.toHaveBeenCalled();
expect(loadTrip).not.toHaveBeenCalled();
choose(result, [gpx()]);
let first!: Promise<void>;
act(() => {
first = result.current.handleImport();
});
expect(result.current.loading).toBe(true);
await runImport(result);
expect(importGpx).toHaveBeenCalledTimes(1);
await act(async () => {
release({ count: 1, skipped: 0, places: [] });
await first;
});
expect(result.current.loading).toBe(false);
});
});
@@ -0,0 +1,320 @@
import type React from 'react';
import { useEffect, useEffectEvent, useState } from 'react';
import { placesApi } from '../../api/client';
import type { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import type { useToast } from '../shared/Toast';
export interface PlacesImportSummary {
totalPlacemarks: number;
createdCount: number;
skippedCount: number;
warnings: string[];
errors: string[];
}
export type GpxImportOption = 'waypoints' | 'routes' | 'tracks';
export type KmlImportOption = 'points' | 'paths';
export interface PlacesFileImportOptions {
tripId: number;
t: ReturnType<typeof useTranslation>['t'];
toast: ReturnType<typeof useToast>;
loadTrip: (tripId: number) => Promise<unknown>;
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void;
/** Runs after an import with no error and no KML summary left to show. */
onDone: () => void;
/**
* The two shells read an import differently, and each keeps its own reading.
*
* `dialog` (desktop): one toast and one undo label for the whole selection, a KML
* answer counts as skipped only what its summary says, a pick of nothing but
* unsupported files keeps the last summary on screen, and a clean import resets the
* dialog before it closes, as Cancel does.
*
* `sheet` (phone): a toast per format, the undo step named for both formats when both
* came in, a KML answer without a summary still counts its top-level skips, a pick of
* nothing usable clears the summary too, and a clean import hands straight back.
*/
variant: 'dialog' | 'sheet';
/** The desktop dialog's opening: each one starts clean, with a file dropped on the sidebar already chosen. */
open?: boolean;
initialFile?: File | null;
}
const MAX_FILE_BYTES = 10 * 1024 * 1024;
const extensionOf = (f: File) => f.name.toLowerCase().split('.').pop();
function mergeSummaries(merged: PlacesImportSummary | null, s: PlacesImportSummary): PlacesImportSummary {
return merged
? {
totalPlacemarks: merged.totalPlacemarks + s.totalPlacemarks,
createdCount: merged.createdCount + s.createdCount,
skippedCount: merged.skippedCount + s.skippedCount,
warnings: [...merged.warnings, ...(s.warnings ?? [])],
errors: [...merged.errors, ...(s.errors ?? [])],
}
: s;
}
/**
* GPX/KML/KMZ file import into the trip's places: the one logic path behind the desktop
* dialog and the phone's import step, which render their own markup over it. It checks
* the files, keeps which kinds of content to bring in, sends each file to its endpoint,
* reloads the trip, offers an undo step that deletes what came in, and keeps a KML
* summary or the errors on screen; `variant` keeps the shells' own readings apart.
*/
export function usePlacesFileImport({
tripId,
t,
toast,
loadTrip,
pushUndo,
onDone,
variant,
open,
initialFile,
}: PlacesFileImportOptions) {
const [files, setFiles] = useState<File[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [summary, setSummary] = useState<PlacesImportSummary | null>(null);
const [gpxOpts, setGpxOpts] = useState({ waypoints: true, routes: true, tracks: true });
const [kmlOpts, setKmlOpts] = useState({ points: true, paths: true });
// The Google pass a list import offers, for the points of a file too (#2536).
const canEnrich = useAuthStore((s) => s.hasMapsKey);
const [enrich, setEnrich] = useState(false);
const validateFile = (f: File): string | null => {
const ext = extensionOf(f);
if (ext !== 'gpx' && ext !== 'kml' && ext !== 'kmz') return t('places.importFileUnsupported');
if (f.size > MAX_FILE_BYTES) return t('places.importFileTooLarge', { maxMb: 10 });
return null;
};
const reset = () => {
setFiles([]);
setIsDragOver(false);
setLoading(false);
setError('');
setSummary(null);
};
/** The desktop dialog's way out, by Cancel or after a clean import. */
const close = () => {
reset();
onDone();
};
// When the dialog opens, reset state and pre-load any file dropped from the sidebar.
const prepareOpening = useEffectEvent((file: File | null | undefined) => {
setIsDragOver(false);
setLoading(false);
setSummary(null);
if (file) {
const err = validateFile(file);
if (err) {
setFiles([]);
setError(err);
} else {
setFiles([file]);
setError('');
}
} else {
setFiles([]);
setError('');
}
});
useEffect(() => {
if (!open) return;
prepareOpening(initialFile);
}, [open, initialFile]);
const selectFiles = (incoming: File[]) => {
if (incoming.length === 0) return;
const valid: File[] = [];
let firstError: string | null = null;
for (const f of incoming) {
const validationError = validateFile(f);
if (validationError) firstError = firstError ?? validationError;
else valid.push(f);
}
if (valid.length === 0 && variant === 'dialog') {
setError(firstError ?? '');
setFiles([]);
return;
}
setFiles(valid);
setError(firstError ?? '');
setSummary(null);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.files ? Array.from(e.target.files) : [];
e.target.value = '';
if (list.length) selectFiles(list);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
if (e.target === e.currentTarget) setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const list = Array.from(e.dataTransfer.files);
if (list.length) selectFiles(list);
};
const toggleGpxOpt = (key: GpxImportOption) => setGpxOpts((prev) => ({ ...prev, [key]: !prev[key] }));
const toggleKmlOpt = (key: KmlImportOption) => setKmlOpts((prev) => ({ ...prev, [key]: !prev[key] }));
const handleImport = async () => {
if (files.length === 0 || loading) return;
setLoading(true);
setError('');
setSummary(null);
// Counted per format so a mixed selection can get each half's own label.
let gpxCreated = 0;
let kmlCreated = 0;
let gpxSkipped = 0;
// A KML answer's skips as its summary counts them, and with the top-level count
// standing in for a missing summary.
let kmlSummarySkipped = 0;
let kmlSkipped = 0;
const createdIds: number[] = [];
const gpxIds: number[] = [];
const kmlIds: number[] = [];
const errors: string[] = [];
let mergedSummary: PlacesImportSummary | null = null;
let importedGpx = false;
let importedKml = false;
for (const f of files) {
const ext = extensionOf(f);
try {
if (ext === 'gpx') {
importedGpx = true;
const result = await placesApi.importGpx(tripId, f, { ...gpxOpts, enrich: enrich && canEnrich });
gpxCreated += result.count ?? 0;
gpxSkipped += result.skipped ?? 0;
if (result.places?.length > 0) {
const ids = result.places.map((p: { id: number }) => p.id);
createdIds.push(...ids);
gpxIds.push(...ids);
}
} else {
importedKml = true;
const result = await placesApi.importMapFile(tripId, f, { ...kmlOpts, enrich: enrich && canEnrich });
kmlCreated += result.count ?? 0;
if (result.places?.length > 0) {
const ids = result.places.map((p: { id: number }) => p.id);
createdIds.push(...ids);
kmlIds.push(...ids);
}
const s = result.summary as PlacesImportSummary | undefined;
if (s) {
mergedSummary = mergeSummaries(mergedSummary, s);
kmlSummarySkipped += s.skippedCount ?? 0;
}
kmlSkipped += s?.skippedCount ?? result.skipped ?? 0;
}
} catch (err: unknown) {
const message =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error || t('places.importFileError');
errors.push(files.length > 1 ? `${f.name}: ${message}` : message);
}
}
await loadTrip(tripId);
const sheet = variant === 'sheet';
const undoIds = sheet ? [...gpxIds, ...kmlIds] : createdIds;
if (undoIds.length > 0) {
let undoLabel: string;
if (!sheet) undoLabel = importedGpx && !importedKml ? t('undo.importGpx') : t('undo.importKeyholeMarkup');
else if (gpxIds.length > 0 && kmlIds.length > 0) undoLabel = t('undo.importFiles');
else undoLabel = gpxIds.length > 0 ? t('undo.importGpx') : t('undo.importKeyholeMarkup');
pushUndo?.(undoLabel, async () => {
try {
await placesApi.bulkDelete(tripId, undoIds);
} catch {
// best effort: the trip reload below reflects whatever happened
}
await loadTrip(tripId);
});
}
if (sheet) {
if (gpxCreated > 0) toast.success(t('places.gpxImported', { count: gpxCreated }));
if (kmlCreated > 0) toast.success(t('places.kmlKmzImported', { count: kmlCreated }));
if (gpxCreated === 0 && kmlCreated === 0 && gpxSkipped + kmlSkipped > 0 && errors.length === 0) {
toast.warning(t('places.importAllSkipped'));
}
} else {
const totalCreated = gpxCreated + kmlCreated;
if (totalCreated > 0) {
const key = importedKml && !importedGpx ? 'places.kmlKmzImported' : 'places.gpxImported';
toast.success(t(key, { count: totalCreated }));
} else if (gpxSkipped + kmlSummarySkipped > 0 && errors.length === 0) {
toast.warning(t('places.importAllSkipped'));
}
}
if (mergedSummary) setSummary(mergedSummary);
if (errors.length > 0) {
setError(errors.join('\n'));
toast.error(errors[0]);
}
setLoading(false);
// Close once everything succeeded and there's no KML summary left to surface.
if (errors.length === 0 && !mergedSummary) {
if (sheet) onDone();
else close();
}
};
const exts = files.map((f) => extensionOf(f) ?? '');
const isGpx = exts.includes('gpx');
const isKml = exts.some((e) => e === 'kml' || e === 'kmz');
const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks;
const kmlNoneSelected = isKml && !kmlOpts.points && !kmlOpts.paths;
const canImport = files.length > 0 && !loading && !gpxNoneSelected && !kmlNoneSelected;
return {
files,
loading,
error,
summary,
gpxOpts,
toggleGpxOpt,
kmlOpts,
toggleKmlOpt,
enrich,
setEnrich,
canEnrich,
isDragOver,
handleDragOver,
handleDragLeave,
handleDrop,
handleInputChange,
handleImport,
close,
isGpx,
isKml,
gpxNoneSelected,
kmlNoneSelected,
canImport,
};
}
@@ -1,18 +1,9 @@
import { useRef, useState } from 'react'
import { useRef } from 'react'
import { Check, FileDown } from 'lucide-react'
import { placesApi } from '../../../../api/client'
import { useAuthStore } from '../../../../store/authStore'
import MToggle from '../../../components/MToggle'
import { Eyebrow, FormSheetFooter } from './PlSheetChrome'
import type { TripPlanner } from '../MTripShell'
interface ImportSummary {
totalPlacemarks: number
createdCount: number
skippedCount: number
warnings: string[]
errors: string[]
}
import { usePlacesFileImport } from '../../../../components/Planner/usePlacesFileImport'
interface ImpFileStepProps {
planner: TripPlanner
@@ -22,8 +13,6 @@ interface ImpFileStepProps {
onDone: () => void
}
const MAX_FILE_BYTES = 10 * 1024 * 1024
/**
* GPX/KML/KMZ file import step — same endpoints and undo behaviour as the
* desktop FileImportModal (placesApi.importGpx / importMapFile), reduced to a
@@ -32,126 +21,10 @@ const MAX_FILE_BYTES = 10 * 1024 * 1024
export default function ImpFileStep({ planner, onBack, onDone }: ImpFileStepProps) {
const { t, toast, tripId, tripActions, pushUndo } = planner
const inputRef = useRef<HTMLInputElement>(null)
const [files, setFiles] = useState<File[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [summary, setSummary] = useState<ImportSummary | null>(null)
const [gpxOpts, setGpxOpts] = useState({ waypoints: true, routes: true, tracks: true })
const [kmlOpts, setKmlOpts] = useState({ points: true, paths: true })
// The Google pass the list import offers, for a file's points too (#2536).
const canEnrich = useAuthStore(s => s.hasMapsKey)
const [enrich, setEnrich] = useState(false)
const validateFile = (f: File): string | null => {
const ext = f.name.toLowerCase().split('.').pop()
if (ext !== 'gpx' && ext !== 'kml' && ext !== 'kmz') return t('places.importFileUnsupported')
if (f.size > MAX_FILE_BYTES) return t('places.importFileTooLarge', { maxMb: 10 })
return null
}
const selectFiles = (incoming: File[]) => {
const valid: File[] = []
let firstError: string | null = null
for (const f of incoming) {
const validationError = validateFile(f)
if (validationError) firstError = firstError ?? validationError
else valid.push(f)
}
setFiles(valid)
setError(firstError ?? '')
setSummary(null)
}
const handleImport = async () => {
if (files.length === 0 || loading) return
setLoading(true)
setError('')
setSummary(null)
// Counted per format so a mixed selection gets each half's own label.
let gpxCreated = 0
let kmlCreated = 0
let totalSkipped = 0
const gpxIds: number[] = []
const kmlIds: number[] = []
const errors: string[] = []
let mergedSummary: ImportSummary | null = null
for (const f of files) {
const ext = f.name.toLowerCase().split('.').pop()
try {
if (ext === 'gpx') {
const result = await placesApi.importGpx(tripId, f, { ...gpxOpts, enrich: enrich && canEnrich })
gpxCreated += result.count ?? 0
totalSkipped += result.skipped ?? 0
if (result.places?.length > 0) gpxIds.push(...result.places.map((p: { id: number }) => p.id))
} else {
const result = await placesApi.importMapFile(tripId, f, { ...kmlOpts, enrich: enrich && canEnrich })
kmlCreated += result.count ?? 0
if (result.places?.length > 0) kmlIds.push(...result.places.map((p: { id: number }) => p.id))
const s = result.summary as ImportSummary | undefined
if (s) {
mergedSummary = mergedSummary
? {
totalPlacemarks: mergedSummary.totalPlacemarks + s.totalPlacemarks,
createdCount: mergedSummary.createdCount + s.createdCount,
skippedCount: mergedSummary.skippedCount + s.skippedCount,
warnings: [...mergedSummary.warnings, ...(s.warnings ?? [])],
errors: [...mergedSummary.errors, ...(s.errors ?? [])],
}
: s
}
// A response without a summary carries the count on the top level.
totalSkipped += s?.skippedCount ?? result.skipped ?? 0
}
} catch (err: unknown) {
const message =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error || t('places.importFileError')
errors.push(files.length > 1 ? `${f.name}: ${message}` : message)
}
}
await tripActions.loadTrip(tripId)
const createdIds = [...gpxIds, ...kmlIds]
if (createdIds.length > 0) {
const undoLabel = gpxIds.length > 0 && kmlIds.length > 0
? t('undo.importFiles')
: gpxIds.length > 0 ? t('undo.importGpx') : t('undo.importKeyholeMarkup')
pushUndo(undoLabel, async () => {
try {
await placesApi.bulkDelete(tripId, createdIds)
} catch {
// best effort — the trip reload below reflects whatever happened
}
await tripActions.loadTrip(tripId)
})
}
if (gpxCreated > 0) toast.success(t('places.gpxImported', { count: gpxCreated }))
if (kmlCreated > 0) toast.success(t('places.kmlKmzImported', { count: kmlCreated }))
if (gpxCreated === 0 && kmlCreated === 0 && totalSkipped > 0 && errors.length === 0) {
toast.warning(t('places.importAllSkipped'))
}
if (mergedSummary) setSummary(mergedSummary)
if (errors.length > 0) {
setError(errors.join('\n'))
toast.error(errors[0])
}
setLoading(false)
// Close once everything succeeded and there's no KML summary left to show.
if (errors.length === 0 && !mergedSummary) onDone()
}
const exts = files.map(f => f.name.toLowerCase().split('.').pop() ?? '')
const isGpx = exts.includes('gpx')
const isKml = exts.some(e => e === 'kml' || e === 'kmz')
const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks
const kmlNoneSelected = isKml && !kmlOpts.points && !kmlOpts.paths
const canImport = files.length > 0 && !loading && !gpxNoneSelected && !kmlNoneSelected
const {
files, loading, error, summary, gpxOpts, toggleGpxOpt, kmlOpts, toggleKmlOpt, canEnrich, enrich, setEnrich,
handleInputChange, handleImport, isGpx, isKml, gpxNoneSelected, kmlNoneSelected, canImport,
} = usePlacesFileImport({ tripId, t, toast, loadTrip: tripActions.loadTrip, pushUndo, onDone, variant: 'sheet' })
return (
<>
@@ -164,11 +37,7 @@ export default function ImpFileStep({ planner, onBack, onDone }: ImpFileStepProp
accept=".gpx,.kml,.kmz"
multiple
className="hidden"
onChange={e => {
const list = e.target.files ? Array.from(e.target.files) : []
e.target.value = ''
if (list.length) selectFiles(list)
}}
onChange={handleInputChange}
/>
<button
type="button"
@@ -195,7 +64,7 @@ export default function ImpFileStep({ planner, onBack, onDone }: ImpFileStepProp
{ key: 'routes', label: t('places.gpxImportRoutes'), on: gpxOpts.routes },
{ key: 'tracks', label: t('places.gpxImportTracks'), on: gpxOpts.tracks },
]}
onToggle={key => setGpxOpts(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }))}
onToggle={toggleGpxOpt}
noneSelected={gpxNoneSelected}
noneSelectedLabel={t('places.gpxImportNoneSelected')}
/>
@@ -207,7 +76,7 @@ export default function ImpFileStep({ planner, onBack, onDone }: ImpFileStepProp
{ key: 'points', label: t('places.kmlImportPoints'), on: kmlOpts.points },
{ key: 'paths', label: t('places.kmlImportPaths'), on: kmlOpts.paths },
]}
onToggle={key => setKmlOpts(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }))}
onToggle={toggleKmlOpt}
noneSelected={kmlNoneSelected}
noneSelectedLabel={t('places.kmlImportNoneSelected')}
/>
@@ -258,16 +127,16 @@ export default function ImpFileStep({ planner, onBack, onDone }: ImpFileStepProp
)
}
interface ImpTypeTogglesProps {
interface ImpTypeTogglesProps<K extends string> {
title: string
options: { key: string; label: string; on: boolean }[]
onToggle: (key: string) => void
options: { key: K; label: string; on: boolean }[]
onToggle: (key: K) => void
noneSelected: boolean
noneSelectedLabel: string
}
/** GPX/KML entity checkboxes ("what do you want to import?"). */
function ImpTypeToggles({ title, options, onToggle, noneSelected, noneSelectedLabel }: ImpTypeTogglesProps) {
function ImpTypeToggles<K extends string>({ title, options, onToggle, noneSelected, noneSelectedLabel }: ImpTypeTogglesProps<K>) {
return (
<div className="mt-3">
<Eyebrow className="mb-[5px] uppercase">{title}</Eyebrow>