feat(collections): import a file into a list that already exists

The import dialog always made a new list. It now asks where the places go:
a new list, as before and still the default, or a list that is already there.
The picker offers the lists the person may edit, which is their own plus a
shared one where they are an editor or an admin, and starts on the list they
have open.

Adding to a list only ever adds. A place the list already holds is left
exactly as it is, with its status, notes, rating and labels, and counted as
already there in what the import reports; it is recognised the way a single
save recognises one, by provider id, then name, then position. The list keeps
its own name, colour, icon and description whatever the file says, a label
the file brings is created only when the list has none of that name, and new
places are appended after the ones already there so a manual order survives.

The route is POST /api/addons/collections/:id/import, 200 because it adds to
a list rather than making one, and both imports now write their places through
the same helper.
This commit is contained in:
Maurice
2026-09-19 23:12:25 +02:00
parent 7cfec6452d
commit d803efd39d
36 changed files with 978 additions and 81 deletions
+4
View File
@@ -12,6 +12,7 @@ import type {
CollectionImportablesResponse,
CollectionFile,
CollectionImportRequest,
CollectionImportIntoRequest,
CollectionImportResult,
CollectionGpxExport,
CollectionGpxReadRequest,
@@ -65,6 +66,9 @@ export const collectionsApi = {
ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data),
importFile: (body: CollectionImportRequest): Promise<CollectionImportResult> =>
ax.post(`${base}/import`, body satisfies CollectionImportRequest).then((r: AxiosResponse) => r.data),
/** The same file into a list that already exists: it only ever adds to it. */
importFileInto: (id: number, body: CollectionImportIntoRequest): Promise<CollectionImportResult> =>
ax.post(`${base}/${id}/import`, body satisfies CollectionImportIntoRequest).then((r: AxiosResponse) => r.data),
// The same list as GPX (#2301). A GPX is read by the server into a list file,
// which then goes through importFile above like any other.
exportGpx: (id: number): Promise<CollectionGpxExport> =>
@@ -1,7 +1,7 @@
// FE-COMP-COLLIMPORT-001 to FE-COMP-COLLIMPORT-016
// FE-COMP-COLLIMPORT-001 to FE-COMP-COLLIMPORT-026
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionFile } from '@trek/shared';
import type { Collection, CollectionFile } from '@trek/shared';
import { useTranslation } from '../../i18n/TranslationContext';
import ImportCollectionModal from './ImportCollectionModal';
import type { GpxReader } from './collectionFile';
@@ -233,3 +233,98 @@ describe('ImportCollectionModal with a GPX (#2301)', () => {
expect(screen.getByText('Choose a list file')).toBeInTheDocument();
});
});
// ── The same file into a list that is already there (#2301 follow-up) ────────
describe('ImportCollectionModal into an existing list', () => {
const lists = [
{ id: 7, owner_id: 1, name: 'Lisbon 2027', color: '#22c55e', place_count: 4 },
{ id: 9, owner_id: 1, name: 'Porto', color: '#3b82f6', place_count: 2 },
] as Collection[];
const intoProps = (over: Partial<React.ComponentProps<typeof ImportCollectionModal>> = {}) =>
renderModal({ onImportInto: vi.fn().mockResolvedValue(undefined), lists, ...over });
it('FE-COMP-COLLIMPORT-020: offers the choice only once a file has been read', async () => {
intoProps();
expect(screen.queryByText('Add to a list')).not.toBeInTheDocument();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
expect(screen.getByText('New list')).toBeInTheDocument();
// A new list stays the default, so nothing changes for anyone who ignores it.
expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument();
expect(importButton()).toBeEnabled();
});
it('FE-COMP-COLLIMPORT-021: keeps to a new list when there is nothing to add to', async () => {
renderModal({ onImportInto: vi.fn(), lists: [] });
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
expect(screen.queryByText('Add to a list')).not.toBeInTheDocument();
});
it('FE-COMP-COLLIMPORT-022: adds the file to the list that was picked, and asks for no name', async () => {
const props = intoProps();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
expect(screen.queryByDisplayValue('Lisbon')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('Porto'));
await userEvent.click(screen.getByRole('button', { name: 'Add to list' }));
await waitFor(() => expect(props.onImportInto).toHaveBeenCalledWith(expect.objectContaining({ name: 'Lisbon' }), 9));
expect(props.onImport).not.toHaveBeenCalled();
});
it('FE-COMP-COLLIMPORT-023: starts on the list that is open, whatever order they come in', async () => {
const props = intoProps({ defaultListId: 9 });
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
await userEvent.click(screen.getByRole('button', { name: 'Add to list' }));
await waitFor(() => expect(props.onImportInto).toHaveBeenCalledWith(expect.anything(), 9));
});
it('FE-COMP-COLLIMPORT-024: says what adding to a list does and does not do', async () => {
intoProps();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
expect(screen.getByText(/Places the list already has stay as they are/)).toBeInTheDocument();
});
it('FE-COMP-COLLIMPORT-025: keeps the dialog open and says why when adding fails', async () => {
const onImportInto = vi.fn().mockRejectedValue(
Object.assign(new Error('x'), { response: { status: 403, data: { error: 'You have read-only access to this list' } } }),
);
intoProps({ onImportInto });
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
await userEvent.click(screen.getByRole('button', { name: 'Add to list' }));
await waitFor(() => expect(screen.getByText('You have read-only access to this list')).toBeInTheDocument());
});
it('FE-COMP-COLLIMPORT-026: goes back to a new list with the name it started with', async () => {
const props = intoProps();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
await userEvent.click(screen.getByText('New list'));
await userEvent.click(importButton());
await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(expect.objectContaining({ name: 'Lisbon' }), undefined));
expect(props.onImportInto).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,7 @@
import React, { useRef, useState } from 'react'
import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle, Route, Info } from 'lucide-react'
import React, { useMemo, useRef, useState } from 'react'
import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle, Route, Info, Plus, FolderInput, Check, Search, Bookmark } from 'lucide-react'
import Modal from '../shared/Modal'
import { MAX_COLLECTION_FILE_PLACES, type CollectionFile } from '@trek/shared'
import { MAX_COLLECTION_FILE_PLACES, type Collection, type CollectionFile } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { getApiErrorMessage } from '../../types'
import {
@@ -15,6 +15,12 @@ import {
interface ImportCollectionModalProps {
onImport: (file: CollectionFile, name?: string) => Promise<void>
/** Adds the file to a list that is already there. Without it the dialog only makes new ones. */
onImportInto?: (file: CollectionFile, collectionId: number) => Promise<void>
/** The lists this person may add to; the server applies the same rule again. */
lists?: Collection[]
/** The list that is open, so the obvious target is the one already selected. */
defaultListId?: number | null
/** Reads a GPX into a list file (#2301); the server does the parsing. */
onReadGpx: GpxReader
onClose: () => void
@@ -50,6 +56,95 @@ function GpxNotes({ leftovers, placeCount, t }: { leftovers: GpxLeftovers; place
)
}
/** One of the two answers to "where do these places go", as a card you can tap. */
function TargetCard({ active, icon: Icon, title, hint, onClick }: {
active: boolean
icon: typeof Plus
title: string
hint: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`relative flex flex-col items-start gap-1.5 px-3 py-3 rounded-xl border text-left transition-colors ${
active ? 'border-accent bg-surface-hover' : 'border-edge hover:bg-surface-hover'
}`}
>
<span className={`w-8 h-8 rounded-lg flex items-center justify-center ${active ? 'bg-accent text-accent-text' : 'bg-surface-input text-content-muted'}`}>
<Icon size={15} />
</span>
<span className="text-[13px] font-semibold text-content">{title}</span>
<span className="text-[11.5px] text-content-faint leading-snug">{hint}</span>
{active && (
<span className="absolute top-2 right-2 w-4 h-4 rounded-full bg-accent text-accent-text flex items-center justify-center">
<Check size={11} strokeWidth={3} />
</span>
)}
</button>
)
}
/** The lists the file may be added to, the open one first. */
function ListChoice({ lists, selectedId, onSelect, t }: {
lists: Collection[]
selectedId: number | null
onSelect: (id: number) => void
t: TranslationFn
}) {
const [search, setSearch] = useState('')
const shown = useMemo(() => {
const q = search.trim().toLowerCase()
return q ? lists.filter(l => l.name.toLowerCase().includes(q)) : lists
}, [lists, search])
return (
<div className="flex flex-col gap-2">
{lists.length > 5 && (
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.file.searchLists')}
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</div>
)}
<div className="flex flex-col gap-1 max-h-[34vh] overflow-y-auto -mx-1 px-1">
{shown.map(list => {
const active = list.id === selectedId
return (
<button
key={list.id}
type="button"
onClick={() => onSelect(list.id)}
aria-pressed={active}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors ${
active ? 'border-accent bg-surface-hover' : 'border-edge bg-surface-card hover:bg-surface-hover'
}`}
>
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: list.color || '#6366f1' }}>
<Bookmark size={15} />
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{list.name}</span>
<span className="block text-[11.5px] text-content-faint">{t('collections.placeCount', { count: list.place_count ?? 0 })}</span>
</span>
{active && <Check size={16} className="shrink-0 text-accent" />}
</button>
)
})}
{shown.length === 0 && (
<p className="text-center text-[13px] text-content-faint py-6">{t('collections.noOtherLists')}</p>
)}
</div>
</div>
)
}
/**
* Read a list file or a GPX and make a list of it (#2198, #2301).
*
@@ -64,8 +159,15 @@ function GpxNotes({ leftovers, placeCount, t }: { leftovers: GpxLeftovers; place
* read by the server into the same kind of list file, and from then on the two
* are one path: what goes to the import is a list file, through the same
* contract the server validates against.
*
* The file can go into a list that is already there instead of a new one. That
* is a choice rather than the default, because the two do different things: a
* new list is the file as it stands, while adding to a list leaves everything
* in it alone and skips the places it already has.
*/
export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t }: ImportCollectionModalProps): React.ReactElement {
export default function ImportCollectionModal({
onImport, onImportInto, lists, defaultListId, onReadGpx, onClose, t,
}: ImportCollectionModalProps): React.ReactElement {
const inputRef = useRef<HTMLInputElement>(null)
const [file, setFile] = useState<CollectionFile | null>(null)
const [leftovers, setLeftovers] = useState<GpxLeftovers | null>(null)
@@ -75,6 +177,16 @@ export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t
const [reading, setReading] = useState(false)
const [busy, setBusy] = useState(false)
const [dragging, setDragging] = useState(false)
const [target, setTarget] = useState<'new' | 'existing'>('new')
// The open list first: it is the one somebody importing from inside a list means.
const targets = useMemo(() => {
const all = lists ?? []
const open = all.find(l => l.id === defaultListId)
return open ? [open, ...all.filter(l => l.id !== open.id)] : all
}, [lists, defaultListId])
const [listId, setListId] = useState<number | null>(() => targets[0]?.id ?? null)
const canChoose = !!onImportInto && targets.length > 0
const intoExisting = canChoose && target === 'existing'
const take = async (chosen: File | undefined) => {
if (!chosen || reading) return
@@ -109,8 +221,12 @@ export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t
setError(null)
setFailedMessage(null)
try {
const trimmed = name.trim()
await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined)
if (intoExisting && listId != null) {
await onImportInto!(file, listId)
} else {
const trimmed = name.trim()
await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined)
}
} catch (err) {
setError('failed')
setFailedMessage(getApiErrorMessage(err, t('common.error')))
@@ -140,11 +256,11 @@ export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t
<button
type="button"
onClick={submit}
disabled={!file || busy || !name.trim() || nothingToImport}
disabled={!file || busy || nothingToImport || (intoExisting ? listId == null : !name.trim())}
className="px-4 py-2 rounded-lg text-[13px] font-semibold bg-accent text-accent-text disabled:opacity-50 transition-opacity inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('collections.file.confirm')}
{intoExisting ? t('collections.file.confirmInto') : t('collections.file.confirm')}
</button>
</div>
}
@@ -203,17 +319,45 @@ export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t
{leftovers && <GpxNotes leftovers={leftovers} placeCount={file.places.length} t={t} />}
<label className="flex flex-col gap-1.5">
<span className="text-[12px] font-medium text-content-muted">{t('collections.listName')}</span>
<input
value={name}
onChange={e => setName(e.target.value)}
maxLength={120}
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</label>
{canChoose && (
<div className="grid grid-cols-2 gap-2">
<TargetCard
active={target === 'new'}
icon={Plus}
title={t('collections.file.targetNew')}
hint={t('collections.file.targetNewHint')}
onClick={() => setTarget('new')}
/>
<TargetCard
active={intoExisting}
icon={FolderInput}
title={t('collections.file.targetExisting')}
hint={t('collections.file.targetExistingHint')}
onClick={() => setTarget('existing')}
/>
</div>
)}
{!leftovers && <p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>}
{intoExisting ? (
<>
<ListChoice lists={targets} selectedId={listId} onSelect={setListId} t={t} />
<p className="text-[11.5px] text-content-faint">{t('collections.file.intoHint')}</p>
</>
) : (
<>
<label className="flex flex-col gap-1.5">
<span className="text-[12px] font-medium text-content-muted">{t('collections.listName')}</span>
<input
value={name}
onChange={e => setName(e.target.value)}
maxLength={120}
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</label>
{!leftovers && <p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>}
</>
)}
</>
)}
+4 -1
View File
@@ -345,10 +345,13 @@ function CollectionsPageDesktop(): React.ReactElement {
/>
)}
{/* A list file as a new list — no active list needed, it makes one (#2198) */}
{/* A list file as a new list, or added to one that is already there (#2198, #2301) */}
{c.showImportFile && (
<ImportCollectionModal
onImport={c.handleImportFile}
onImportInto={c.handleImportFileInto}
lists={c.writableLists}
defaultListId={typeof c.activeId === 'number' ? c.activeId : null}
onReadGpx={c.handleReadGpx}
onClose={() => c.setShowImportFile(false)}
t={t}
@@ -1,4 +1,4 @@
// FE-PAGE-COLL-001 to FE-PAGE-COLL-081
// FE-PAGE-COLL-001 to FE-PAGE-COLL-085
// The Collections page hook. The collection store is replaced by a fixture so
// every handler/branch can be driven directly; the categories request goes
// through MSW, and the websocket module is already mocked in tests/setup.ts.
@@ -1063,6 +1063,65 @@ describe('useCollections — export and import as a file', () => {
expect(result.current.showImportFile).toBe(true)
})
it('FE-PAGE-COLL-082: a file added to another list lands on it and says what it did', async () => {
const seen: unknown[] = []
server.use(http.post('/api/addons/collections/:id/import', async ({ request, params }) => {
seen.push({ id: params.id, body: await request.json() })
return HttpResponse.json({ collection: { id: 11, name: 'Lisbon 2027' }, imported: 2, skipped: 0, duplicates: 1 })
}))
const { result } = await setup()
await act(async () => { await result.current.handleImportFileInto(listFile as never, 11) })
expect(seen[0]).toEqual({ id: '11', body: { file: listFile } })
expect(store.loadAll).toHaveBeenCalled()
expect(navigate).toHaveBeenCalledWith('/collections/11')
expect(addToast).toHaveBeenCalledWith('2 added to Lisbon 2027, 1 were already there', 'success', undefined)
expect(result.current.showImportFile).toBe(false)
})
it('FE-PAGE-COLL-083: adding to the list that is open refreshes it instead of navigating', async () => {
server.use(http.post('/api/addons/collections/:id/import', () =>
HttpResponse.json({ collection: { id: 11, name: 'Lisbon 2027' }, imported: 2, skipped: 0, duplicates: 0 })))
store.activeId = 11
const { result } = await setup()
navigate.mockClear()
await act(async () => { await result.current.handleImportFileInto(listFile as never, 11) })
expect(store.refreshActive).toHaveBeenCalled()
expect(navigate).not.toHaveBeenCalledWith('/collections/11')
expect(addToast).toHaveBeenCalledWith('2 places added to Lisbon 2027', 'success', undefined)
})
it('FE-PAGE-COLL-084: a file whose places were all there already says so instead of claiming a success', async () => {
server.use(http.post('/api/addons/collections/:id/import', () =>
HttpResponse.json({ collection: { id: 11, name: 'Lisbon 2027' }, imported: 0, skipped: 0, duplicates: 2 })))
const { result } = await setup()
await act(async () => { await result.current.handleImportFileInto(listFile as never, 11) })
expect(addToast).toHaveBeenCalledWith('Every place in the file is already in Lisbon 2027', 'info', undefined)
})
it('FE-PAGE-COLL-085: only lists this person may write to are offered as a target', async () => {
const shared = (id: number, role: string | null) => ({
...collection({ id, is_owner: false }),
members: role === null ? [] : [{ user_id: 7, username: 'me', status: 'accepted', role }],
}) as unknown as Collection
store.collections = [
collection({ id: 1 }),
shared(2, 'editor'),
shared(3, 'admin'),
shared(4, 'viewer'),
shared(5, null),
{ ...shared(6, 'editor'), members: [{ user_id: 7, username: 'me', status: 'pending', role: 'editor' }] } as unknown as Collection,
]
const { result } = await setup()
expect(result.current.writableLists.map(l => l.id)).toEqual([1, 2, 3])
})
it('FE-PAGE-COLL-076: a renamed import carries the new name to the server', async () => {
const seen: unknown[] = []
server.use(http.post('/api/addons/collections/import', async ({ request }) => {
+40 -1
View File
@@ -170,6 +170,17 @@ export function useCollections() {
useEffect(() => { setShowShare(false) }, [activeId])
const ownedLists = useMemo(() => collections.filter(c => c.is_owner !== false), [collections])
/**
* Lists a file may be added to: the person's own, plus a shared one where
* they are an editor or an admin. Same rule the server applies on the way in,
* so the import dialog never offers a list the import would refuse.
*/
const writableLists = useMemo(
() => collections.filter(c => c.is_owner !== false || (c.members ?? []).some(
m => m.user_id === currentUserId && m.status === 'accepted' && (m.role === 'editor' || m.role === 'admin'),
)),
[collections, currentUserId],
)
const sharedLists = useMemo(() => collections.filter(c => c.is_owner === false), [collections])
// Labels are per-collection, so never apply them on the "All saved" union.
@@ -260,6 +271,33 @@ export function useCollections() {
setShowImportFile(false)
}, [loadAll, navigate, toast, t])
/**
* Read a chosen file into a list that is already there.
*
* Lands on that list, for the same reason a new one does. Nothing in it is
* overwritten: the server counts the places it already had and leaves them,
* and the toast says so rather than letting a file quietly do less than it
* looked like it would.
*/
const handleImportFileInto = useCallback(async (file: CollectionFile, collectionId: number) => {
const result = await collectionsApi.importFileInto(collectionId, { file })
const name = (result.collection as Collection).name
const duplicates = result.duplicates ?? 0
await loadAll()
if (activeId === collectionId) refreshActive()
else navigate(`/collections/${collectionId}`)
if (result.imported === 0 && duplicates > 0) {
toast.info(t('collections.file.doneIntoNothing', { name }))
} else if (duplicates > 0) {
toast.success(t('collections.file.doneIntoDuplicates', { count: result.imported, duplicates, name }))
} else if (result.skipped > 0) {
toast.info(t('collections.file.doneSkipped', { count: result.imported, skipped: result.skipped }))
} else {
toast.success(t('collections.file.doneInto', { count: result.imported, name }))
}
setShowImportFile(false)
}, [activeId, loadAll, refreshActive, navigate, toast, t])
const handleDeleteList = useCallback(async () => {
if (confirmDeleteList == null) return
const id = confirmDeleteList
@@ -467,7 +505,8 @@ export function useCollections() {
showAddPlace, setShowAddPlace, handlePlaceAdded,
showImport, setShowImport,
exporting, handleExportList,
showImportFile, setShowImportFile, handleImportFile, handleReadGpx,
showImportFile, setShowImportFile, handleImportFile, handleImportFileInto, handleReadGpx,
writableLists,
confirmDeleteList, setConfirmDeleteList,
mobileRailOpen, setMobileRailOpen,
showShare, setShowShare, handleAfterLeave,