mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user