mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
v1
This commit is contained in:
@@ -894,19 +894,115 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── ICS export click ─────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-058: clicking ICS button calls fetch for .ics export', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-058: clicking ICS button first asks link or download', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
|
||||
} as any)
|
||||
// Mock URL.createObjectURL
|
||||
if (!navigator.clipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
const url = String(input)
|
||||
const method = (init?.method || 'GET').toUpperCase()
|
||||
|
||||
if (url === '/api/trips/1/subscribe.ics' && method === 'GET') {
|
||||
return {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: null }),
|
||||
} as any
|
||||
}
|
||||
|
||||
if (url === '/api/trips/1/subscribe.ics' && method === 'POST') {
|
||||
return {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
url: 'https://example.com/api/shared/token/calendar.ics',
|
||||
webcal_url: 'webcal://example.com/api/shared/token/calendar.ics',
|
||||
}),
|
||||
} as any
|
||||
}
|
||||
|
||||
if (url === '/api/trips/1/subscribe.ics' && method === 'DELETE') {
|
||||
return { ok: true } as any
|
||||
}
|
||||
|
||||
if (url === '/api/trips/1/export.ics' && method === 'GET') {
|
||||
return {
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
|
||||
} as any
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch call: ${method} ${url}`)
|
||||
})
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
await user.click(screen.getByText('ICS').closest('button')!)
|
||||
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object)))
|
||||
expect(await screen.findByText('Calendar share')).toBeInTheDocument()
|
||||
expect(screen.getByText('Create a subscription link for calendar apps, or download the ICS file.')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create link' }))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'POST' })))
|
||||
|
||||
expect(screen.getByDisplayValue('https://example.com/api/shared/token/calendar.ics')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Copy' }))
|
||||
await waitFor(() => expect(clipboardSpy).toHaveBeenCalledWith('https://example.com/api/shared/token/calendar.ics'))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete link' }))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'DELETE' })))
|
||||
expect(screen.getByRole('button', { name: 'Create link' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Download ICS file' }))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
|
||||
expect(createObjURL).toHaveBeenCalled()
|
||||
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
|
||||
|
||||
fetchSpy.mockRestore()
|
||||
clipboardSpy.mockRestore()
|
||||
createObjURL.mockRestore()
|
||||
revokeObjURL.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-097: opening ICS dialog shows existing generated link when present', async () => {
|
||||
const user = userEvent.setup()
|
||||
const expectedUrl = `${window.location.origin}/api/shared/existing-token/calendar.ics`
|
||||
const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: 'existing-token' }),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
|
||||
} as any)
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
await user.click(screen.getByText('ICS').closest('button')!)
|
||||
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object)))
|
||||
expect(await screen.findByDisplayValue(expectedUrl)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Create link' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Copy' }))
|
||||
await waitFor(() => expect(clipboardSpy).toHaveBeenCalledWith(expectedUrl))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Download ICS file' }))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
|
||||
expect(createObjURL).toHaveBeenCalled()
|
||||
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
|
||||
expect(fetchSpy).not.toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.objectContaining({ method: 'POST' }))
|
||||
|
||||
fetchSpy.mockRestore()
|
||||
clipboardSpy.mockRestore()
|
||||
createObjURL.mockRestore()
|
||||
revokeObjURL.mockRestore()
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon, Link2, Copy } from 'lucide-react'
|
||||
|
||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
@@ -252,6 +252,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [transportDetail, setTransportDetail] = useState(null)
|
||||
const [icsDialog, setIcsDialog] = useState<{ url: string; webcal_url: string; creating: boolean } | null>(null)
|
||||
const [icsCopied, setIcsCopied] = useState(false)
|
||||
const icsCopyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [transportPosVersion, setTransportPosVersion] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -284,6 +287,99 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (icsCopyTimerRef.current) clearTimeout(icsCopyTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const closeIcsDialog = () => setIcsDialog(null)
|
||||
|
||||
const handleIcsOpenDialog = async () => {
|
||||
setIcsCopied(false)
|
||||
setIcsDialog({ url: '', webcal_url: '', creating: true })
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error()
|
||||
const data = await res.json() as { token?: string | null }
|
||||
if (data.token) {
|
||||
const url = `${window.location.origin}/api/shared/${encodeURIComponent(data.token)}/calendar.ics`
|
||||
const webcal_url = url.replace(/^https?:\/\//, 'webcal://')
|
||||
setIcsDialog({ url, webcal_url, creating: false })
|
||||
} else {
|
||||
setIcsDialog({ url: '', webcal_url: '', creating: false })
|
||||
}
|
||||
} catch {
|
||||
setIcsDialog({ url: '', webcal_url: '', creating: false })
|
||||
toast.error(t('dayplan.calendarLinkFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleIcsCreateLink = async () => {
|
||||
if (icsDialog?.creating) return
|
||||
setIcsDialog(prev => prev ? { ...prev, creating: true } : { url: '', webcal_url: '', creating: true })
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, { method: 'POST', credentials: 'include' })
|
||||
if (!res.ok) throw new Error()
|
||||
const data = await res.json() as { url?: string; webcal_url?: string }
|
||||
const shareUrl = data.url
|
||||
const openUrl = data.webcal_url || data.url
|
||||
if (!shareUrl || !openUrl) throw new Error()
|
||||
setIcsDialog({ url: shareUrl, webcal_url: openUrl, creating: false })
|
||||
} catch {
|
||||
setIcsDialog(prev => prev ? { ...prev, creating: false } : prev)
|
||||
toast.error(t('dayplan.calendarLinkFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleIcsCopyLink = async () => {
|
||||
if (!icsDialog?.url) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(icsDialog.url)
|
||||
setIcsCopied(true)
|
||||
if (icsCopyTimerRef.current) clearTimeout(icsCopyTimerRef.current)
|
||||
icsCopyTimerRef.current = setTimeout(() => setIcsCopied(false), 2000)
|
||||
} catch {
|
||||
toast.error(t('dayplan.calendarCopyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleIcsDeleteLink = async () => {
|
||||
if (!icsDialog || icsDialog.creating) return
|
||||
setIcsDialog(prev => prev ? { ...prev, creating: true } : prev)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/subscribe.ics`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
setIcsCopied(false)
|
||||
setIcsDialog(prev => prev ? { ...prev, url: '', webcal_url: '', creating: false } : prev)
|
||||
toast.success(t('dayplan.calendarLinkDeleted'))
|
||||
} catch {
|
||||
setIcsDialog(prev => prev ? { ...prev, creating: false } : prev)
|
||||
toast.error(t('dayplan.calendarDeleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleIcsDownload = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(t('dayplan.calendarDownloaded'))
|
||||
closeIcsDialog()
|
||||
} catch {
|
||||
toast.error(t('dayplan.calendarExportFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
|
||||
const getDragData = (e) => {
|
||||
const dt = e?.dataTransfer
|
||||
@@ -993,21 +1089,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error(t('planner.icsExportFailed')) }
|
||||
}}
|
||||
onClick={handleIcsOpenDialog}
|
||||
onMouseEnter={() => setIcsHover(true)}
|
||||
onMouseLeave={() => setIcsHover(false)}
|
||||
style={{
|
||||
@@ -2128,6 +2210,101 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* ICS subscription dialog */}
|
||||
{icsDialog && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={closeIcsDialog}>
|
||||
<div style={{
|
||||
width: 420, maxWidth: '92vw', background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12, position: 'relative',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={closeIcsDialog}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
width: 30,
|
||||
height: 30,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border-faint)',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||
<Link2 size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{t('dayplan.calendarShareTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', margin: 0, lineHeight: 1.5 }}>
|
||||
{t('dayplan.calendarShareDescription')}
|
||||
</p>
|
||||
{icsDialog.url ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px',
|
||||
background: 'var(--bg-tertiary)', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<input type="text" value={icsDialog.url} readOnly style={{
|
||||
flex: 1, border: 'none', background: 'none', fontSize: 11, color: 'var(--text-primary)',
|
||||
outline: 'none', fontFamily: 'monospace',
|
||||
}} />
|
||||
<button onClick={handleIcsCopyLink} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
|
||||
border: 'none', background: icsCopied ? '#16a34a' : 'var(--accent)', color: icsCopied ? 'white' : 'var(--accent-text)',
|
||||
fontSize: 10, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
|
||||
}}>
|
||||
{icsCopied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={handleIcsDeleteLink} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Trash2 size={11} /> {t('dayplan.calendarDeleteLink')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleIcsCreateLink} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)',
|
||||
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={12} /> {t('dayplan.calendarCreateLink')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleIcsDownload}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 0', borderRadius: 8, border: 'none',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 12, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('dayplan.calendarDownloadFile')}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Transport-Detail-Modal */}
|
||||
{transportDetail && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
|
||||
@@ -305,6 +305,17 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notificationsActive': 'Active channel',
|
||||
'settings.notificationsManagedByAdmin': 'Notification events are configured by your administrator.',
|
||||
'dayplan.icsTooltip': 'Export calendar (ICS)',
|
||||
'dayplan.calendarShareTitle': 'Calendar share',
|
||||
'dayplan.calendarShareDescription': 'Create a subscription link for calendar apps, or download the ICS file.',
|
||||
'dayplan.calendarCreateLink': 'Create link',
|
||||
'dayplan.calendarDeleteLink': 'Delete link',
|
||||
'dayplan.calendarDownloadFile': 'Download ICS file',
|
||||
'dayplan.calendarLinkFailed': 'Calendar link failed',
|
||||
'dayplan.calendarDeleteFailed': 'Delete link failed',
|
||||
'dayplan.calendarCopyFailed': 'Copy failed',
|
||||
'dayplan.calendarDownloaded': 'ICS downloaded',
|
||||
'dayplan.calendarExportFailed': 'ICS export failed',
|
||||
'dayplan.calendarLinkDeleted': 'Calendar link deleted',
|
||||
'share.linkTitle': 'Public Link',
|
||||
'share.linkHint': 'Create a link anyone can use to view this trip without logging in. Read-only — no editing possible.',
|
||||
'share.createLink': 'Create link',
|
||||
|
||||
Reference in New Issue
Block a user