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