diff --git a/client/src/components/Planner/FileImportModal.tsx b/client/src/components/Planner/FileImportModal.tsx index 59e3362f3..d3b684c35 100644 --- a/client/src/components/Planner/FileImportModal.tsx +++ b/client/src/components/Planner/FileImportModal.tsx @@ -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(null) const titleId = useId() - - const [files, setFiles] = useState([]) - const [isDragOver, setIsDragOver] = useState(false) - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - const [summary, setSummary] = useState(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) => { - 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 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 setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))} + onToggle={() => toggleKmlOpt(key)} label={t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')} /> ))} diff --git a/client/src/components/Planner/usePlacesFileImport.test.ts b/client/src/components/Planner/usePlacesFileImport.test.ts new file mode 100644 index 000000000..59f90a397 --- /dev/null +++ b/client/src/components/Planner/usePlacesFileImport.test.ts @@ -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) => + params ? `${key}:${Object.values(params).join(',')}` : key; + +const gpx = (name = 'route.gpx') => new File([''], name, { type: 'application/gpx+xml' }); +const kml = (name = 'places.kml') => new File([''], 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; + +function setup(over: Partial = {}) { + 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) => {}); + 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 }, files: File[]) { + const target = { files, value: 'C:\\fakepath\\x' }; + act(() => result.current.handleInputChange({ target } as unknown as React.ChangeEvent)); + return target; +} + +async function runImport(result: { current: ReturnType }) { + 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; + 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); + }); +}); diff --git a/client/src/components/Planner/usePlacesFileImport.ts b/client/src/components/Planner/usePlacesFileImport.ts new file mode 100644 index 000000000..b1f4fc470 --- /dev/null +++ b/client/src/components/Planner/usePlacesFileImport.ts @@ -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['t']; + toast: ReturnType; + loadTrip: (tripId: number) => Promise; + pushUndo?: (label: string, undoFn: () => Promise | 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([]); + const [isDragOver, setIsDragOver] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [summary, setSummary] = useState(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) => { + 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, + }; +} diff --git a/client/src/mobile/screens/trip/sheets/ImpFileStep.tsx b/client/src/mobile/screens/trip/sheets/ImpFileStep.tsx index f6b430ddf..a6bebf9cf 100644 --- a/client/src/mobile/screens/trip/sheets/ImpFileStep.tsx +++ b/client/src/mobile/screens/trip/sheets/ImpFileStep.tsx @@ -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(null) - - const [files, setFiles] = useState([]) - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - const [summary, setSummary] = useState(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} />