feat(places): unified file import modal with drag-and-drop and deduplication

- Replace separate GPX and KML/KMZ import buttons with a single "Import
  file" modal accepting all three formats, with a drag-and-drop drop zone
- Support dragging files directly onto the Places sidebar panel; overlay
  appears on hover and pre-loads the file into the modal on drop
- Fix [object Object] description bug in KML imports caused by
  fast-xml-parser returning mixed-content nodes as objects; add stopNodes
  config and object guard in asTrimmedString
- Fix CDATA sections leaking into descriptions (e.g. "text.]]>") by
  unwrapping CDATA markers before tag stripping
- Add import deduplication across all import paths (GPX, KML/KMZ, Google
  list, Naver list): reimporting skips places already in the trip by name
  (case-insensitive) or by coordinates (within ~11 m tolerance), with
  intra-batch dedup so duplicate placemarks within the same file are
  also collapsed
- Fix KML route returning 400 "No valid Placemarks found" when all
  placemarks were valid but deduplicated; 400 now only fires when the
  file contains zero placemarks
- Show a warning toast "All places were already in the trip" instead of
  a misleading success toast when a reimport produces zero new places
  (GPX, KML/KMZ, Google list, Naver list)
- Add 8 new i18n keys across all 14 locales; remove 11 keys made unused
  by the modal consolidation
This commit is contained in:
jubnl
2026-04-15 06:07:26 +02:00
parent 801ffbfb7b
commit 875c91e5ff
22 changed files with 741 additions and 431 deletions
@@ -433,29 +433,29 @@ describe('Mobile day-picker (portal)', () => {
// ── GPX import ────────────────────────────────────────────────────────────────
describe('GPX import', () => {
it('FE-PLANNER-SIDEBAR-038: GPX import button triggers file input click', async () => {
it('FE-PLANNER-SIDEBAR-038: "Import file" button opens the file import modal', async () => {
const user = userEvent.setup();
render(<PlacesSidebar {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement;
expect(fileInput).toBeTruthy();
const clickSpy = vi.spyOn(fileInput, 'click');
await user.click(screen.getByText(/GPX/i));
expect(clickSpy).toHaveBeenCalled();
await user.click(screen.getByText(/Import file/i));
expect(await screen.findByText(/\.gpx.*\.kml.*\.kmz/i)).toBeInTheDocument();
});
it('FE-PLANNER-SIDEBAR-039: successful GPX import shows success toast', async () => {
// FormData POST hangs on CI — mock at the API boundary instead of MSW.
it('FE-PLANNER-SIDEBAR-039: successful GPX import via modal shows success toast', async () => {
const importSpy = vi.spyOn(placesApi, 'importGpx').mockResolvedValueOnce({ count: 2, places: [{ id: 10 }, { id: 11 }] });
const loadTrip = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { loadTrip });
const addToast = vi.fn();
(window as any).__addToast = addToast;
const user = userEvent.setup();
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement;
await user.click(screen.getByText(/Import file/i));
const fileInput = document.querySelector('input[type="file"][accept=".gpx,.kml,.kmz"]') as HTMLInputElement;
expect(fileInput).toBeTruthy();
const file = new File(['track data'], 'route.gpx', { type: 'application/gpx+xml' });
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});
await user.click(screen.getByRole('button', { name: /^import$/i }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining('2'),