mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -122,6 +122,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dashboard.toast.copied': 'Trip copied!',
|
'dashboard.toast.copied': 'Trip copied!',
|
||||||
'dashboard.toast.copyError': 'Failed to copy trip',
|
'dashboard.toast.copyError': 'Failed to copy trip',
|
||||||
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
|
'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.editTrip': 'Edit Trip',
|
||||||
'dashboard.createTrip': 'Create New Trip',
|
'dashboard.createTrip': 'Create New Trip',
|
||||||
'dashboard.tripTitle': 'Title',
|
'dashboard.tripTitle': 'Title',
|
||||||
|
|||||||
@@ -401,6 +401,10 @@ describe('DashboardPage', () => {
|
|||||||
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
|
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
|
||||||
await user.click(copyButtons[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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
|
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -766,6 +770,10 @@ describe('DashboardPage', () => {
|
|||||||
expect(copyButtons.length).toBeGreaterThan(0);
|
expect(copyButtons.length).toBeGreaterThan(0);
|
||||||
await user.click(copyButtons[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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
|
|||||||
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
||||||
import TripFormModal from '../components/Trips/TripFormModal'
|
import TripFormModal from '../components/Trips/TripFormModal'
|
||||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||||
|
import CopyTripDialog from '../components/shared/CopyTripDialog'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import { useCountUp } from '../hooks/useCountUp'
|
import { useCountUp } from '../hooks/useCountUp'
|
||||||
import {
|
import {
|
||||||
@@ -699,6 +700,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
|
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 [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
||||||
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||||
|
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
||||||
|
|
||||||
const toggleViewMode = () => {
|
const toggleViewMode = () => {
|
||||||
setViewMode(prev => {
|
setViewMode(prev => {
|
||||||
@@ -815,14 +817,18 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
setArchivedTrips(prev => prev.map(update))
|
setArchivedTrips(prev => prev.map(update))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopy = async (trip: DashboardTrip) => {
|
const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip)
|
||||||
|
|
||||||
|
const confirmCopy = async () => {
|
||||||
|
if (!copyTrip) return
|
||||||
try {
|
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]))
|
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||||
toast.success(t('dashboard.toast.copied'))
|
toast.success(t('dashboard.toast.copied'))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('dashboard.toast.copyError'))
|
toast.error(t('dashboard.toast.copyError'))
|
||||||
}
|
}
|
||||||
|
setCopyTrip(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
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 || '' })}
|
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CopyTripDialog
|
||||||
|
isOpen={!!copyTrip}
|
||||||
|
tripTitle={copyTrip?.title || ''}
|
||||||
|
onClose={() => setCopyTrip(null)}
|
||||||
|
onConfirm={confirmCopy}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1 }
|
0%, 100% { opacity: 1 }
|
||||||
|
|||||||
@@ -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);
|
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);
|
return Number(newTripId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -950,6 +950,52 @@ describe('Copy trip with data', () => {
|
|||||||
expect(newNotes).toHaveLength(1);
|
expect(newNotes).toHaveLength(1);
|
||||||
expect(newNotes[0].text).toBe('Pack early!');
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user