Merge pull request #797 from mauriceboe/fix/786-copy-trip-todos-budget-order

fix(trips): copy todo_items and budget_category_order when duplicating a trip
This commit is contained in:
Julien G.
2026-04-21 20:51:18 +02:00
committed by GitHub
6 changed files with 209 additions and 2 deletions
@@ -0,0 +1,108 @@
import React, { useEffect, useCallback } from 'react'
import { Check, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
interface CopyTripDialogProps {
isOpen: boolean
tripTitle: string
onClose: () => void
onConfirm: () => void
}
const WILL_COPY_KEYS = [
'dashboard.confirm.copy.will1',
'dashboard.confirm.copy.will2',
'dashboard.confirm.copy.will3',
'dashboard.confirm.copy.will4',
'dashboard.confirm.copy.will5',
'dashboard.confirm.copy.will6',
]
const WONT_COPY_KEYS = [
'dashboard.confirm.copy.wont1',
'dashboard.confirm.copy.wont2',
'dashboard.confirm.copy.wont3',
'dashboard.confirm.copy.wont4',
]
export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }: CopyTripDialogProps) {
const { t } = useTranslation()
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (isOpen) document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [isOpen, handleEsc])
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
onClick={onClose}
>
<div
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-md p-6"
style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()}
>
<h3 className="text-base font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
{t('dashboard.confirm.copy.title')}
</h3>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
{tripTitle}
</p>
<div className="flex flex-col gap-3">
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: '#16a34a' }}>
{t('dashboard.confirm.copy.willCopy')}
</p>
<ul className="flex flex-col gap-1">
{WILL_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<Check size={13} className="flex-shrink-0" style={{ color: '#16a34a' }} />
{t(key)}
</li>
))}
</ul>
</div>
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-muted)' }}>
{t('dashboard.confirm.copy.wontCopy')}
</p>
<ul className="flex flex-col gap-1">
{WONT_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<X size={13} className="flex-shrink-0" style={{ color: 'var(--text-muted)' }} />
{t(key)}
</li>
))}
</ul>
</div>
</div>
<div className="flex justify-end gap-3 mt-5">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)', border: '1px solid var(--border-secondary)' }}
>
{t('common.cancel')}
</button>
<button
onClick={() => { onConfirm(); onClose() }}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white bg-blue-600 hover:bg-blue-700"
>
{t('dashboard.confirm.copy.confirm')}
</button>
</div>
</div>
</div>
)
}
+14
View File
@@ -122,6 +122,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.copied': 'Trip copied!',
'dashboard.toast.copyError': 'Failed to copy trip',
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
'dashboard.confirm.copy.title': 'Copy this trip?',
'dashboard.confirm.copy.willCopy': 'Will be copied',
'dashboard.confirm.copy.will1': 'Days, places & day assignments',
'dashboard.confirm.copy.will2': 'Accommodations & reservations',
'dashboard.confirm.copy.will3': 'Budget items & category order',
'dashboard.confirm.copy.will4': 'Packing lists (unchecked)',
'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)',
'dashboard.confirm.copy.will6': 'Day notes',
'dashboard.confirm.copy.wontCopy': "Won't be copied",
'dashboard.confirm.copy.wont1': 'Collaborators & member assignments',
'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages',
'dashboard.confirm.copy.wont3': 'Files & photos',
'dashboard.confirm.copy.wont4': 'Share tokens',
'dashboard.confirm.copy.confirm': 'Copy trip',
'dashboard.editTrip': 'Edit Trip',
'dashboard.createTrip': 'Create New Trip',
'dashboard.tripTitle': 'Title',
+8
View File
@@ -401,6 +401,10 @@ describe('DashboardPage', () => {
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
await user.click(copyButtons[0]);
// Confirm the copy dialog
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
await user.click(confirmButton);
await waitFor(() => {
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
});
@@ -766,6 +770,10 @@ describe('DashboardPage', () => {
expect(copyButtons.length).toBeGreaterThan(0);
await user.click(copyButtons[0]);
// Confirm the copy dialog
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
await user.click(confirmButton);
await waitFor(() => {
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
});
+15 -2
View File
@@ -12,6 +12,7 @@ import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
import TripFormModal from '../components/Trips/TripFormModal'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import CopyTripDialog from '../components/shared/CopyTripDialog'
import { useToast } from '../components/shared/Toast'
import { useCountUp } from '../hooks/useCountUp'
import {
@@ -699,6 +700,7 @@ export default function DashboardPage(): React.ReactElement {
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
const toggleViewMode = () => {
setViewMode(prev => {
@@ -815,14 +817,18 @@ export default function DashboardPage(): React.ReactElement {
setArchivedTrips(prev => prev.map(update))
}
const handleCopy = async (trip: DashboardTrip) => {
const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip)
const confirmCopy = async () => {
if (!copyTrip) return
try {
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` })
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.copied'))
} catch {
toast.error(t('dashboard.toast.copyError'))
}
setCopyTrip(null)
}
const today = new Date().toISOString().split('T')[0]
@@ -1205,6 +1211,13 @@ export default function DashboardPage(): React.ReactElement {
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
/>
<CopyTripDialog
isOpen={!!copyTrip}
tripTitle={copyTrip?.title || ''}
onClose={() => setCopyTrip(null)}
onConfirm={confirmCopy}
/>
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1 }
+18
View File
@@ -681,6 +681,24 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number,
if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order);
}
const oldTodos = db.prepare('SELECT * FROM todo_items WHERE trip_id = ?').all(sourceTripId) as any[];
const insertTodo = db.prepare(`
INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, assigned_user_id, priority)
VALUES (?, ?, 0, ?, ?, ?, ?, NULL, ?)
`);
for (const t of oldTodos) {
insertTodo.run(newTripId, t.name, t.category, t.sort_order, t.due_date, t.description, t.priority);
}
const oldCategoryOrder = db.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ?').all(sourceTripId) as any[];
const insertCategoryOrder = db.prepare(`
INSERT INTO budget_category_order (trip_id, category, sort_order)
VALUES (?, ?, ?)
`);
for (const o of oldCategoryOrder) {
insertCategoryOrder.run(newTripId, o.category, o.sort_order);
}
return Number(newTripId);
});
+46
View File
@@ -950,6 +950,52 @@ describe('Copy trip with data', () => {
expect(newNotes).toHaveLength(1);
expect(newNotes[0].text).toBe('Pack early!');
});
it('TRIP-027 — copy preserves todos (unchecked, unassigned) and budget category order', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Todo Trip' });
// Two todos: one checked and assigned — both should arrive unchecked and unassigned
testDb.prepare(
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(trip.id, 'Buy tickets', 0, 'Transport', 0, '2026-06-01', 'Check Ryanair', 1);
testDb.prepare(
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, assigned_user_id, priority) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(trip.id, 'Book hotel', 1, 'Accommodation', 1, user.id, 0);
// Two budget category order rows
const insOrder = testDb.prepare('INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)');
insOrder.run(trip.id, 'Transport', 0);
insOrder.run(trip.id, 'Accommodation', 1);
const res = await request(app)
.post(`/api/trips/${trip.id}/copy`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Todo Trip (Copy)' });
expect(res.status).toBe(201);
const newId = res.body.trip.id;
// Todos copied with checked reset and assigned_user_id nulled
const newTodos = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[];
expect(newTodos).toHaveLength(2);
expect(newTodos[0].name).toBe('Buy tickets');
expect(newTodos[0].category).toBe('Transport');
expect(newTodos[0].checked).toBe(0);
expect(newTodos[0].assigned_user_id).toBeNull();
expect(newTodos[0].due_date).toBe('2026-06-01');
expect(newTodos[0].description).toBe('Check Ryanair');
expect(newTodos[0].priority).toBe(1);
expect(newTodos[1].name).toBe('Book hotel');
expect(newTodos[1].checked).toBe(0);
expect(newTodos[1].assigned_user_id).toBeNull();
// Budget category order copied
const newOrder = testDb.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[];
expect(newOrder).toHaveLength(2);
expect(newOrder[0]).toMatchObject({ category: 'Transport', sort_order: 0 });
expect(newOrder[1]).toMatchObject({ category: 'Accommodation', sort_order: 1 });
});
});
// ─────────────────────────────────────────────────────────────────────────────