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