feat(collections): import a whole trip's places into a list (#1903)

* feat(collections): preview which of a trip's places a list already holds

The bulk import endpoint has been there since the select-mode copy, but nothing
calls it and it only reports duplicates after the fact. A dialog needs the verdict
up front, so /:id/importable/:tripId returns the trip's places with the duplicate
check the import itself applies, plus whether any day holds the place.

* feat(collections): import a whole trip's places into a list

Two steps: pick a trip, then tick its places. Places already on the list are
greyed out and unpickable, and the ones no day holds start selected — those are
the plans a trip left behind, which is the reason to import at all. Sorted so a
mostly-saved trip reads as the two that are left rather than a wall of grey.

* feat(collections): reach the trip import from the filter row and an empty list

* i18n(collections): translate the trip import into all locales

* fix(collections): stop the label dialog's buttons wrapping mid-word

Three actions in a max-w-sm footer wrapped into two lines each. The assign
button also painted white text on the accent, which disappears on a light one.

* docs(collections): document the trip import
This commit is contained in:
Maurice
2026-08-12 11:54:36 +02:00
committed by GitHub
parent b0fd73681b
commit b842d74a5d
37 changed files with 1286 additions and 11 deletions
@@ -39,7 +39,7 @@ export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign,
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="sm">
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="lg">
{labels.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<Tags size={26} className="text-content-faint" />
@@ -67,18 +67,20 @@ export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign,
)
})}
</div>
{/* nowrap on all three: the labels are long in several locales and used to
wrap into two lines inside the buttons before the dialog got wider. */}
<div className="flex justify-end gap-2 pt-2 border-t border-edge">
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary whitespace-nowrap hover:bg-surface-hover">
<Settings2 size={14} /> {t('collections.labels.manage')}
</button>
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] whitespace-nowrap hover:bg-surface-hover">
{t('common.cancel')}
</button>
<button
type="button"
onClick={assign}
disabled={picked.length === 0 || busy}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold whitespace-nowrap hover:opacity-90 disabled:opacity-50"
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('collections.labels.assign')}
</button>
@@ -41,6 +41,8 @@ function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
onLabelFilter: vi.fn(),
canManageLabels: false,
onManageLabels: vi.fn(),
canImport: false,
onImport: vi.fn(),
showSelect: true,
selectMode: false,
onToggleSelect: vi.fn(),
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import { ChevronDown, Check, Layers, Tag, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react'
import { ChevronDown, Check, Layers, Tag, CheckSquare, Star, Plus, ArrowDownUp, DownloadCloud } from 'lucide-react'
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
import type { TranslationFn } from '../../types'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -87,6 +87,10 @@ interface CollectionFilterBarProps {
showSelect: boolean
selectMode: boolean
onToggleSelect: () => void
/** Bulk import from a trip — only on a real list, never the "All saved" union,
* which has no single destination to import into. */
canImport: boolean
onImport: () => void
t: TranslationFn
}
@@ -100,7 +104,7 @@ export default function CollectionFilterBar({
onStatusFilter, onCategoryFilter, onRatingFilter, onSortMode,
canAddPlace, onAddPlace,
showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels,
showSelect, selectMode, onToggleSelect, t,
showSelect, selectMode, onToggleSelect, canImport, onImport, t,
}: CollectionFilterBarProps): React.ReactElement {
const statusOpts: Opt[] = [
{ key: 'all', label: t('common.all'), count: counts.all },
@@ -160,6 +164,17 @@ export default function CollectionFilterBar({
<CheckSquare size={15} />
</button>
)}
{canImport && (
<button
type="button"
onClick={onImport}
className="col-filter-btn col-filter-icon"
aria-label={t('collections.importFromTrip')}
title={t('collections.importFromTrip')}
>
<DownloadCloud size={15} />
</button>
)}
{showLabels && (labelOptions.length > 0 || canManageLabels) && (
<CollectionLabelFilter
labelOptions={labelOptions}
@@ -0,0 +1,152 @@
// FE-COMP-COLIMPORT-001 to FE-COMP-COLIMPORT-007
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useTranslation } from '../../i18n/TranslationContext';
import ImportFromTripModal from './ImportFromTripModal';
const BASE = '/api/addons/collections';
const TRIPS = [
{ id: 7, user_id: 1, title: 'Rome 2026', currency: 'EUR', is_archived: 0, reminder_days: 3, start_date: '2026-05-01', end_date: '2026-05-08', place_count: 3, cover_image: null },
{ id: 9, user_id: 1, title: 'Lisbon', currency: 'EUR', is_archived: 0, reminder_days: 3, place_count: 0, cover_image: null },
];
const place = (over: Record<string, unknown>) => ({
place_id: 1, name: 'Somewhere', address: null, lat: null, lng: null, category_id: null,
image_url: null, already_in_list: false, scheduled: false, day_number: null, date: null, ...over,
});
// Colosseum sits on day 2, the market was never scheduled, the Pantheon is already saved.
const IMPORTABLE = [
place({ place_id: 11, name: 'Colosseum', scheduled: true, day_number: 2, date: '2026-05-02' }),
place({ place_id: 12, name: 'Testaccio Market' }),
place({ place_id: 13, name: 'Pantheon', already_in_list: true, scheduled: true, day_number: 1 }),
];
function Harness(props: { onImported?: () => void; onClose?: () => void }): React.ReactElement {
const { t } = useTranslation();
return (
<ImportFromTripModal
isOpen
collectionId={4}
collectionName="Italy"
categories={[]}
onClose={props.onClose ?? (() => {})}
onImported={props.onImported ?? (() => {})}
t={t}
/>
);
}
function useHandlers(importable = IMPORTABLE, onPost?: (body: unknown) => void) {
server.use(
http.get('/api/trips', () => HttpResponse.json({ trips: TRIPS })),
http.get(`${BASE}/4/importable/7`, () => HttpResponse.json({ places: importable })),
http.post(`${BASE}/places/from-trip-many`, async ({ request }) => {
onPost?.(await request.json());
return HttpResponse.json({ copied: 2, skipped: [{ id: 13, name: 'Pantheon' }] });
}),
);
}
async function openTrip(user: ReturnType<typeof userEvent.setup>) {
await waitFor(() => expect(screen.getByText('Rome 2026')).toBeInTheDocument());
await user.click(screen.getByText('Rome 2026'));
await waitFor(() => expect(screen.getByText('Colosseum')).toBeInTheDocument());
}
describe('ImportFromTripModal', () => {
beforeEach(() => useHandlers());
it('FE-COMP-COLIMPORT-001: lists the trips with their place count and a localized date range', async () => {
render(<Harness />);
await waitFor(() => expect(screen.getByText('Rome 2026')).toBeInTheDocument());
// The count is a compact badge; the full wording stays as its title.
expect(screen.getByTitle('3 places')).toHaveTextContent('3');
// Formatted through the shared formatter, never the raw ISO string.
const dateBadge = screen.getByTitle(/May/);
expect(dateBadge.textContent).toMatch(/May 1.*–.*May 8/);
expect(dateBadge.textContent).not.toMatch(/2026-05-01/);
expect(screen.getByText('Lisbon')).toBeInTheDocument();
});
it('FE-COMP-COLIMPORT-002: pre-selects the unscheduled place, not the scheduled one', async () => {
const user = userEvent.setup();
render(<Harness />);
await openTrip(user);
// Only Testaccio Market is both new and dayless.
expect(screen.getByRole('button', { name: /Import 1/ })).toBeInTheDocument();
expect(screen.getByText('1 selected')).toBeInTheDocument();
});
it('FE-COMP-COLIMPORT-003: marks a place already on the list and refuses to select it', async () => {
const user = userEvent.setup();
render(<Harness />);
await openTrip(user);
const row = screen.getByText('Pantheon').closest('button')!;
expect(within(row).getByText('Already saved')).toBeInTheDocument();
expect(row).toBeDisabled();
await user.click(row);
expect(screen.getByText('1 selected')).toBeInTheDocument(); // unchanged
});
it('FE-COMP-COLIMPORT-004: shows the day a scheduled place sits on', async () => {
const user = userEvent.setup();
render(<Harness />);
await openTrip(user);
const row = screen.getByText('Colosseum').closest('button')!;
expect(within(row).getByText('Day 2')).toBeInTheDocument();
expect(within(screen.getByText('Testaccio Market').closest('button')!).getByText('Unscheduled')).toBeInTheDocument();
});
it('FE-COMP-COLIMPORT-005: only the selected ids are sent, and the result is reported', async () => {
const onPost = vi.fn();
const onImported = vi.fn();
useHandlers(IMPORTABLE, onPost);
const user = userEvent.setup();
render(<Harness onImported={onImported} />);
await openTrip(user);
// Add the scheduled one to the pre-selected leftover.
await user.click(screen.getByText('Colosseum').closest('button')!);
await user.click(screen.getByRole('button', { name: /Import 2/ }));
await waitFor(() => expect(onPost).toHaveBeenCalled());
const body = onPost.mock.calls[0][0] as { source_place_ids: number[]; source_trip_id: number };
expect(body.source_trip_id).toBe(7);
expect([...body.source_place_ids].sort()).toEqual([11, 12]);
await waitFor(() => expect(screen.getByText('2 places added')).toBeInTheDocument());
expect(screen.getByText(/1 were already on the list/)).toBeInTheDocument();
expect(onImported).toHaveBeenCalled();
});
it('FE-COMP-COLIMPORT-006: the only-new filter hides what is already saved', async () => {
const user = userEvent.setup();
render(<Harness />);
await openTrip(user);
await user.click(screen.getByRole('button', { name: 'Only new' }));
await waitFor(() => expect(screen.queryByText('Pantheon')).not.toBeInTheDocument());
expect(screen.getByText('Colosseum')).toBeInTheDocument();
});
it('FE-COMP-COLIMPORT-007: a trip whose places are all saved says so instead of offering an empty list', async () => {
useHandlers([place({ place_id: 13, name: 'Pantheon', already_in_list: true })]);
const user = userEvent.setup();
render(<Harness />);
await waitFor(() => expect(screen.getByText('Rome 2026')).toBeInTheDocument());
await user.click(screen.getByText('Rome 2026'));
await waitFor(() => expect(screen.getByText(/already on this list/)).toBeInTheDocument());
expect(screen.getByRole('button', { name: /Import 0/ })).toBeDisabled();
});
});
@@ -0,0 +1,340 @@
import React, { useEffect, useMemo, useState } from 'react'
import { ArrowLeft, Calendar, Check, ChevronRight, Loader2, MapPin, Map as MapIcon, Search, Sparkles } from 'lucide-react'
import Modal from '../shared/Modal'
import { tripsApi } from '../../api/client'
import { collectionsApi } from '../../api/collections'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { formatDate } from '../../utils/formatters'
import { getApiErrorMessage } from '../../types'
import type { Category, TranslationFn } from '../../types'
import type { CollectionImportablePlace, Trip } from '@trek/shared'
interface ImportFromTripModalProps {
isOpen: boolean
collectionId: number
collectionName: string
categories: Category[]
onClose: () => void
onImported: () => void
t: TranslationFn
}
type Step = 'trip' | 'places' | 'done'
/** Rows sort new-and-unscheduled first, then the rest of the new ones, and the
* already-saved ones last. A trip whose places are mostly saved already then reads
* as "here are the two left over" instead of a wall of greyed-out rows. */
function importOrder(p: CollectionImportablePlace): number {
if (p.already_in_list) return 2
return p.scheduled ? 1 : 0
}
/** Dates go through the shared formatter, so they follow the reader's locale
* (and drop the year when it is the current one) instead of showing raw ISO. */
function tripRange(trip: Trip, locale: string): string | null {
const from = formatDate(trip.start_date, locale)
const to = formatDate(trip.end_date, locale)
if (from && to && to !== from) return `${from} – ${to}`
return from || to || null
}
/**
* Bulk import of a trip's places into the current list.
*
* Two steps: pick a trip, then pick its places. The duplicate verdict comes from
* the server (`/importable/:tripId`) rather than a second comparison here, so a row
* shown as new can never come back as skipped. Places no day holds are pre-selected:
* those are the ones a trip left behind, which is what this import is for.
*/
export default function ImportFromTripModal({ isOpen, collectionId, collectionName, categories, onClose, onImported, t }: ImportFromTripModalProps): React.ReactElement {
const toast = useToast()
const { language } = useTranslation()
const [step, setStep] = useState<Step>('trip')
const [trips, setTrips] = useState<Trip[]>([])
const [tripsLoading, setTripsLoading] = useState(false)
const [trip, setTrip] = useState<Trip | null>(null)
const [items, setItems] = useState<CollectionImportablePlace[]>([])
const [itemsLoading, setItemsLoading] = useState(false)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [onlyNew, setOnlyNew] = useState(false)
const [query, setQuery] = useState('')
const [importing, setImporting] = useState(false)
const [result, setResult] = useState<{ copied: number; skipped: number } | null>(null)
useEffect(() => {
if (!isOpen) return
setStep('trip'); setTrip(null); setItems([]); setSelected(new Set()); setQuery(''); setOnlyNew(false); setResult(null)
setTripsLoading(true)
tripsApi.list()
.then((data: { trips?: Trip[] }) => setTrips(data.trips ?? []))
.catch(err => toast.error(getApiErrorMessage(err, t('common.error'))))
.finally(() => setTripsLoading(false))
// toast/t are stable enough for a mount effect; re-running on them would refetch on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen])
const pickTrip = async (chosen: Trip) => {
setTrip(chosen); setStep('places'); setItemsLoading(true); setQuery('')
try {
const data = await collectionsApi.importable(collectionId, chosen.id)
setItems(data.places)
// Unscheduled places are the point of the import, so they start selected. A trip
// where everything was scheduled would otherwise open with an empty selection, so
// fall back to every new place there.
const fresh = data.places.filter(p => !p.already_in_list)
const leftovers = fresh.filter(p => !p.scheduled)
setSelected(new Set((leftovers.length ? leftovers : fresh).map(p => p.place_id)))
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
setStep('trip')
} finally {
setItemsLoading(false)
}
}
const newCount = useMemo(() => items.filter(p => !p.already_in_list).length, [items])
const savedCount = items.length - newCount
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
return items
.filter(p => (onlyNew ? !p.already_in_list : true))
.filter(p => !q || p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q))
.sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name))
}, [items, onlyNew, query])
const selectableVisible = visible.filter(p => !p.already_in_list)
const allVisibleSelected = selectableVisible.length > 0 && selectableVisible.every(p => selected.has(p.place_id))
const toggle = (id: number) => {
setSelected(prev => {
const next = new Set(prev)
if (!next.delete(id)) next.add(id)
return next
})
}
const toggleAllVisible = () => {
setSelected(prev => {
const next = new Set(prev)
if (allVisibleSelected) selectableVisible.forEach(p => next.delete(p.place_id))
else selectableVisible.forEach(p => next.add(p.place_id))
return next
})
}
const runImport = async () => {
if (!trip || selected.size === 0) return
setImporting(true)
try {
const res = await collectionsApi.saveFromTripMany(collectionId, trip.id, [...selected])
setResult({ copied: res.copied, skipped: res.skipped.length })
setStep('done')
onImported()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setImporting(false)
}
}
const categoryById = useMemo(() => new Map(categories.map(c => [c.id, c])), [categories])
const title = step === 'places' && trip
? trip.title
: step === 'done'
? t('collections.importDoneTitle')
: t('collections.importTitle')
const footer = step === 'done'
? (
<div className="flex justify-end">
<button type="button" onClick={onClose} className="px-4 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold">{t('common.close')}</button>
</div>
)
: step === 'places'
? (
<div className="flex items-center justify-between gap-3">
<span className="text-[12.5px] text-content-faint">
{t('collections.importSelectedCount', { count: selected.size })}
</span>
<div className="flex gap-2">
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
<button
type="button"
onClick={runImport}
disabled={importing || selected.size === 0}
className="px-4 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-1.5"
>
{importing ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
{t('collections.importAction', { count: selected.size })}
</button>
</div>
</div>
)
: (
<div className="flex justify-end">
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
</div>
)
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="xl" footer={footer}>
<div className="col-imp">
{/* ── Step 1: which trip ─────────────────────────────────────────── */}
{step === 'trip' && (
<>
<p className="col-imp-lead">{t('collections.importPickTripHint', { name: collectionName })}</p>
{tripsLoading ? (
<div className="col-imp-grid">
{[0, 1, 2, 3].map(i => <div key={i} className="col-imp-trip trek-skeleton" style={{ height: 68 }} />)}
</div>
) : trips.length === 0 ? (
<div className="col-imp-empty">
<MapIcon size={22} />
<span>{t('collections.importNoTrips')}</span>
</div>
) : (
<div className="col-imp-grid trek-stagger">
{trips.map(tr => {
const range = tripRange(tr, language)
const places = tr.place_count ?? 0
return (
<button key={tr.id} type="button" className="col-imp-trip" onClick={() => pickTrip(tr)}>
<span className="col-imp-trip-cover">
{tr.cover_image
? <img src={tr.cover_image} alt="" loading="lazy" />
: <MapIcon size={16} />}
</span>
<span className="col-imp-trip-body">
<span className="col-imp-trip-name">{tr.title}</span>
{/* Badges rather than one run-on line: the tile is half the dialog
wide, and a date range plus a place count ran out of room and
got clipped mid-word. Each pill can now wrap as a unit. */}
<span className="col-imp-trip-meta">
{range && (
<span className="col-imp-pill" title={range}>
<Calendar size={10} />{range}
</span>
)}
<span className="col-imp-pill num" title={t('collections.importPlacesCount', { count: places })}>
<MapPin size={10} />{places}
</span>
</span>
</span>
<ChevronRight size={16} className="col-imp-trip-go" />
</button>
)
})}
</div>
)}
</>
)}
{/* ── Step 2: which places ───────────────────────────────────────── */}
{step === 'places' && (
<>
<div className="col-imp-bar">
<button type="button" className="col-imp-back" onClick={() => setStep('trip')} aria-label={t('common.back')}>
<ArrowLeft size={15} />
</button>
<div className="col-imp-search">
<Search size={14} />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('collections.importSearchPlaces')}
aria-label={t('collections.importSearchPlaces')}
/>
</div>
{savedCount > 0 && (
<button
type="button"
className={`col-imp-chip${onlyNew ? ' on' : ''}`}
aria-pressed={onlyNew}
onClick={() => setOnlyNew(v => !v)}
>
<Sparkles size={12} />{t('collections.importOnlyNew')}
</button>
)}
<button
type="button"
className="col-imp-chip"
onClick={toggleAllVisible}
disabled={selectableVisible.length === 0}
>
{allVisibleSelected ? t('collections.importClearSelection') : t('collections.importSelectAll')}
</button>
</div>
{itemsLoading ? (
<div className="col-imp-list">
{[0, 1, 2, 3, 4].map(i => <div key={i} className="col-imp-row trek-skeleton" style={{ height: 56 }} />)}
</div>
) : items.length === 0 ? (
<div className="col-imp-empty">
<MapPin size={22} />
<span>{t('collections.importEmptyTrip')}</span>
</div>
) : newCount === 0 ? (
<div className="col-imp-empty col-imp-empty-good">
<Check size={22} />
<span>{t('collections.importNothingNew')}</span>
</div>
) : (
<div className="col-imp-list trek-stagger">
{visible.map(p => {
const dup = p.already_in_list
const on = selected.has(p.place_id)
const cat = p.category_id != null ? categoryById.get(p.category_id) : undefined
const Icon = getCategoryIcon(cat?.icon ?? undefined)
return (
<button
key={p.place_id}
type="button"
className={`col-imp-row${on ? ' on' : ''}${dup ? ' dup' : ''}`}
onClick={() => !dup && toggle(p.place_id)}
disabled={dup}
aria-pressed={dup ? undefined : on}
>
<span className="col-imp-box">{on && <Check size={12} strokeWidth={3} />}</span>
<span className="col-imp-ico" style={cat?.color ? { color: cat.color } : undefined}>
<Icon size={15} />
</span>
<span className="col-imp-txt">
<span className="nm">{p.name}</span>
{p.address && <span className="ad">{p.address}</span>}
</span>
<span className={`col-imp-tag${dup ? ' saved' : p.scheduled ? '' : ' fresh'}`}>
{dup
? t('collections.importAlreadySaved')
: p.scheduled
? t('collections.importOnDay', { count: p.day_number ?? 0 })
: t('collections.importUnscheduled')}
</span>
</button>
)
})}
</div>
)}
</>
)}
{/* ── Step 3: what happened ──────────────────────────────────────── */}
{step === 'done' && result && (
<div className="col-imp-done">
<span className="col-imp-done-ring"><Check size={26} strokeWidth={2.5} /></span>
<span className="col-imp-done-t">{t('collections.importDone', { count: result.copied })}</span>
<span className="col-imp-done-s">
{result.skipped > 0
? t('collections.importDoneSkipped', { count: result.skipped })
: t('collections.importDoneClean', { name: collectionName })}
</span>
</div>
)}
</div>
</Modal>
)
}