mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
feat(planner): collapse the three export buttons into one dialog
PDF, ICS and GPX each had their own button in the day-plan toolbar, and two of them carried a hover menu of their own. That is five controls' worth of width in a pane the user can drag narrower, so the exports were the first thing pushed off the edge, and a hover menu is not reachable at all once its button is gone. One button opens a dialog instead: the day plan as a PDF, the bookings as a calendar (a one-off .ics or a live subscription) and the map data as GPX in the three scopes a device wants. Every row reports that it is working, which the PDF export needed most: it fetches a 226 kB builder before it renders anything, and until now the toolbar button simply sat there. Two strings the old ICS menu had hardcoded in English, the download and the subscription, come from the locale files the phone export sheet already used. The toolbar no longer needs the icsHover flag it only held to keep its menu open, so that comes out of the sidebar's state as well.
This commit is contained in:
@@ -610,16 +610,17 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── PDF export ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-025: PDF export button is present', () => {
|
||||
it('FE-PLANNER-DAYPLAN-025: the export button is present', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-026: clicking PDF button calls downloadTripPDF', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-026: the PDF row in the export dialog calls downloadTripPDF', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { downloadTripPDF } = await import('../PDF/TripPDF')
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
await user.click(screen.getByText('PDF'))
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await user.click(await screen.findByText('PDF'))
|
||||
await waitFor(() => {
|
||||
expect(downloadTripPDF).toHaveBeenCalled()
|
||||
})
|
||||
@@ -900,11 +901,14 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// Day-title renaming moved to DayDetailPanel (#1065) — covered there.
|
||||
|
||||
// ── ICS export button ────────────────────────────────────────────────────
|
||||
// ── ICS export ───────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-043: ICS export button is present', () => {
|
||||
it('FE-PLANNER-DAYPLAN-043: the calendar export sits in the export dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
expect(screen.getByText('ICS')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
expect(await screen.findByText('Calendar')).toBeInTheDocument()
|
||||
expect(screen.getByText('Download .ics')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── getMergedItems: transport merged with assignments ──────────────────
|
||||
@@ -1120,13 +1124,12 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── PDF hover tooltip ─────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-054: hovering PDF button shows tooltip', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-054: hovering the export button shows its tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
const pdfBtn = screen.getByText('PDF').closest('button')!
|
||||
await user.hover(pdfBtn)
|
||||
await user.hover(screen.getByRole('button', { name: 'Export' }))
|
||||
await waitFor(() => {
|
||||
// Tooltip text appears (from t('dayplan.pdfTooltip'))
|
||||
// Tooltip text appears (from t('dayplan.exportIntro'))
|
||||
const tooltips = document.querySelectorAll('[style*="pointer-events: none"]')
|
||||
expect(tooltips.length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -1194,10 +1197,9 @@ describe('DayPlanSidebar', () => {
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
// The ICS button now opens a hover menu (Download / Subscribe) instead of
|
||||
// downloading on direct click.
|
||||
await user.hover(screen.getByText('ICS').closest('button')!)
|
||||
await user.click(await screen.findByText('Download ICS'))
|
||||
// The three export buttons collapsed into one that opens the export dialog.
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await user.click(await screen.findByText('Download .ics'))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
|
||||
fetchSpy.mockRestore()
|
||||
createObjURL.mockRestore()
|
||||
@@ -1831,8 +1833,8 @@ describe('DayPlanSidebar', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [assignment] },
|
||||
})} />)
|
||||
const pdfBtn = screen.getByRole('button', { name: /pdf/i })
|
||||
await user.click(pdfBtn)
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await user.click(await screen.findByText('PDF'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dayNotes: expect.arrayContaining([expect.objectContaining({ text: 'PDF Note' })]) })
|
||||
))
|
||||
@@ -1916,16 +1918,17 @@ describe('DayPlanSidebar', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── ICS hover tooltip ─────────────────────────────────────────────────────
|
||||
// ── Export dialog ─────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows the download/subscribe menu', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-090: the export button opens the dialog with every format', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
const icsBtn = screen.getByText('ICS').closest('button')!
|
||||
await user.hover(icsBtn)
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument()
|
||||
expect(screen.getByText('Download .ics')).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
|
||||
expect(screen.getByText('Whole trip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -211,7 +211,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
const [lockHoverId, setLockHoverId] = useState(null)
|
||||
const [undoHover, setUndoHover] = useState(false)
|
||||
const [icsHover, setIcsHover] = useState(false)
|
||||
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
|
||||
// Transit rows fold their itinerary out inline (#1065).
|
||||
const [expandedTransitIds, setExpandedTransitIds] = useState<Set<number>>(new Set())
|
||||
@@ -1034,8 +1033,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setLockHoverId,
|
||||
undoHover,
|
||||
setUndoHover,
|
||||
icsHover,
|
||||
setIcsHover,
|
||||
hoveredAssignmentId,
|
||||
setHoveredAssignmentId,
|
||||
dropTargetKey,
|
||||
@@ -1209,8 +1206,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
setLockHoverId,
|
||||
undoHover,
|
||||
setUndoHover,
|
||||
icsHover,
|
||||
setIcsHover,
|
||||
hoveredAssignmentId,
|
||||
setHoveredAssignmentId,
|
||||
dropTargetKey,
|
||||
@@ -1405,8 +1400,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
t={t}
|
||||
locale={locale}
|
||||
toast={toast}
|
||||
icsHover={icsHover}
|
||||
setIcsHover={setIcsHover}
|
||||
expandedDays={expandedDays}
|
||||
setExpandedDays={setExpandedDays}
|
||||
onUndo={onUndo}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-022
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-023
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { buildDay, buildDayNote, buildReservation, buildTrip } from '../../../tests/helpers/factories'
|
||||
import { buildDay, buildReservation, buildTrip } from '../../../tests/helpers/factories'
|
||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }))
|
||||
|
||||
// The subscribe dialog fetches its feed token on mount; it is exercised in its
|
||||
// own test, here we only care that opening the menu entry mounts it.
|
||||
// own test, and TripExportModal covers the entry that opens it.
|
||||
vi.mock('./IcsSubscribeModal', () => ({
|
||||
IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => (
|
||||
<div data-testid="ics-subscribe-modal">
|
||||
@@ -46,8 +46,6 @@ function makeProps(overrides: Partial<React.ComponentProps<typeof DayPlanSidebar
|
||||
t,
|
||||
locale: 'en-US',
|
||||
toast: makeToast(),
|
||||
icsHover: false,
|
||||
setIcsHover: vi.fn((_v: boolean) => {}),
|
||||
expandedDays: new Set<number>(),
|
||||
setExpandedDays: vi.fn((_v: Set<number>) => {}),
|
||||
canUndo: false,
|
||||
@@ -72,37 +70,42 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('DayPlanSidebarToolbar', () => {
|
||||
// ── PDF export ────────────────────────────────────────────────────────────
|
||||
// ── Export ─────────────────────────────────────────────────────
|
||||
// PDF, ICS and GPX each used to be their own button with its own hover menu.
|
||||
// The mechanics now live in TripExportModal and are tested there; what is left
|
||||
// here is the single button that opens it.
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-001: the PDF button exports the trip with the day notes flattened', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = [buildDay({ id: 10, title: 'Day 1' })]
|
||||
const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] }
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ days, dayNotes })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
|
||||
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
|
||||
})
|
||||
it('FE-PLANNER-DPTOOLBAR-001: the toolbar carries one export button, not three', () => {
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.getByRole('button', { name: 'dayplan.export' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('ICS')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('GPX')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-002: a failing PDF export surfaces the error via the toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing'))
|
||||
const toast = makeToast()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing'))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-003: hovering the PDF button shows the export tooltip', async () => {
|
||||
it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument()
|
||||
await user.hover(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(screen.getByText('dayplan.pdfTooltip')).toBeInTheDocument())
|
||||
await user.unhover(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument())
|
||||
const btn = screen.getByRole('button', { name: 'dayplan.export' })
|
||||
expect(btn).toHaveAttribute('aria-expanded', 'false')
|
||||
await user.click(btn)
|
||||
expect(btn).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
await waitFor(() => expect(screen.queryByText('dayplan.exportDocument')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-003: hovering the export button shows its tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument()
|
||||
const btn = screen.getByRole('button', { name: 'dayplan.export' })
|
||||
await user.hover(btn)
|
||||
await waitFor(() => expect(screen.getByText('dayplan.exportIntro')).toBeInTheDocument())
|
||||
await user.unhover(btn)
|
||||
await waitFor(() => expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
// The button sits at the right edge of the leftmost pane. Its own tooltip was
|
||||
@@ -111,8 +114,8 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
it('FE-PLANNER-DPTOOLBAR-004: the export tooltip stays inside the window', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('dayplan.pdf'))
|
||||
const tip = await screen.findByText('dayplan.pdfTooltip')
|
||||
await user.hover(screen.getByRole('button', { name: 'dayplan.export' }))
|
||||
const tip = await screen.findByText('dayplan.exportIntro')
|
||||
expect(tip.closest('[role="tooltip"]')).not.toBeNull()
|
||||
expect(tip.parentElement).toBe(document.body)
|
||||
expect(tip.style.position).toBe('fixed')
|
||||
@@ -120,94 +123,12 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
await waitFor(() => expect(parseFloat(tip.style.left)).toBeGreaterThanOrEqual(0))
|
||||
})
|
||||
|
||||
// ── ICS menu ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-005: hovering ICS opens the download/subscribe menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setIcsHover = vi.fn((_v: boolean) => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ setIcsHover })} />)
|
||||
expect(screen.queryByText('Download ICS')).not.toBeInTheDocument()
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
|
||||
expect(setIcsHover).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-005b: without share_manage the menu offers the download but not the subscription', async () => {
|
||||
// The subscription mints a link that reads the trip without an account, so
|
||||
// it needs share_manage; the one-off download is a file this member may
|
||||
// already read. Leaving the entry visible would only produce a dialog whose
|
||||
// enable button the server refuses.
|
||||
it('FE-PLANNER-DPTOOLBAR-005: the dialog inherits the share permission from the toolbar', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ canManageShare: false })} />)
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Subscribe to calendar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-006: leaving the ICS area closes the menu after the grace period', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setIcsHover = vi.fn((_v: boolean) => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ setIcsHover })} />)
|
||||
const icsButton = screen.getByText('ICS')
|
||||
await user.hover(icsButton)
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
await user.unhover(icsButton)
|
||||
await waitFor(() => expect(screen.queryByText('Download ICS')).not.toBeInTheDocument())
|
||||
expect(setIcsHover).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-007: re-entering the ICS area cancels the pending close', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
const icsButton = screen.getByText('ICS')
|
||||
await user.hover(icsButton)
|
||||
await user.unhover(icsButton)
|
||||
await user.hover(icsButton)
|
||||
// The close is scheduled 120ms out; coming back in has to cancel it.
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-008: "Download ICS" fetches the export and hands it to a download link', 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 unknown as Response)
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
await user.click(screen.getByText('Download ICS'))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' }))
|
||||
await waitFor(() => expect(clickSpy).toHaveBeenCalled())
|
||||
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
|
||||
// The menu closes itself once the download starts.
|
||||
expect(screen.queryByText('Download ICS')).not.toBeInTheDocument()
|
||||
fetchSpy.mockRestore(); createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-009: a rejected ICS export shows the failure toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false } as unknown as Response)
|
||||
const toast = makeToast()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
await user.click(screen.getByText('Download ICS'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed'))
|
||||
fetchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-010: "Subscribe to calendar" opens the subscribe dialog and closes again', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
await user.click(screen.getByText('Subscribe to calendar'))
|
||||
expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('close-subscribe'))
|
||||
expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'dayplan.export' }))
|
||||
expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument()
|
||||
expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Expand / collapse all ────────────────────────────────────────────────
|
||||
@@ -350,105 +271,6 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
expect(routeBtn.style.background).toBe('var(--bg-hover)')
|
||||
await user.unhover(routeBtn)
|
||||
expect(routeBtn.style.background).toBe('transparent')
|
||||
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
const download = screen.getByText('Download ICS')
|
||||
const subscribe = screen.getByText('Subscribe to calendar')
|
||||
await user.hover(download)
|
||||
expect(download.style.background).toContain('var(--bg-hover')
|
||||
// Sliding down to the next entry has to clear the one we left.
|
||||
await user.hover(subscribe)
|
||||
expect(download.style.background).toBe('transparent')
|
||||
expect(subscribe.style.background).toContain('var(--bg-hover')
|
||||
await user.unhover(subscribe)
|
||||
expect(subscribe.style.background).toBe('transparent')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-024: an aborted PDF export without an Error still reaches the toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom')
|
||||
const toast = makeToast()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom'))
|
||||
})
|
||||
|
||||
// ── GPX export (#1442) ────────────────────────────────────────────────────
|
||||
describe('GPX menu', () => {
|
||||
let clickedHref: string | null
|
||||
|
||||
beforeEach(() => {
|
||||
clickedHref = null
|
||||
// jsdom has neither of these, and an anchor click would navigate.
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:gpx')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
clickedHref = this.href
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const okResponse = () => ({ ok: true, status: 200, blob: async () => new Blob(['<gpx/>']) }) as unknown as Response
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-025: the menu offers the three scopes on hover', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.queryByText('dayplan.gpxAll')).not.toBeInTheDocument()
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
expect(screen.getByText('dayplan.gpxAll')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.gpxPlaces')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.gpxDays')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-026: each scope asks the server for exactly its own selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.fn(async () => okResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
|
||||
for (const [label, expected] of [
|
||||
['dayplan.gpxAll', '/api/trips/1/places/export.gpx'],
|
||||
['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'],
|
||||
['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'],
|
||||
] as const) {
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText(label))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' }))
|
||||
fetchMock.mockClear()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-027: a download names the file after the trip', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => okResponse()))
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(clickedHref).toBe('blob:gpx'))
|
||||
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-028: an empty trip says so instead of reporting a failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response))
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty'))
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
expect(clickedHref).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-029: a real failure toasts the error', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus, MapPin, Route as RouteIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, Download, Undo2, ArrowUpDown, Route as RouteIcon } from 'lucide-react'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { IcsSubscribeModal } from './IcsSubscribeModal'
|
||||
import { TripExportModal } from './TripExportModal'
|
||||
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
|
||||
/**
|
||||
* What a GPX download can carry. Worded by what someone wants on their device
|
||||
* rather than by the GPX element it maps to: "everything", "just the places" for
|
||||
* an offline map, "just the days" for following a plan. An omitted flag defaults
|
||||
* to true server-side, so the first entry needs no query at all.
|
||||
*/
|
||||
const GPX_SCOPES = [
|
||||
{ key: 'all', query: '', labelKey: 'dayplan.gpxAll', icon: FileDown },
|
||||
{ key: 'places', query: '?dayRoutes=false', labelKey: 'dayplan.gpxPlaces', icon: MapPin },
|
||||
{ key: 'days', query: '?waypoints=false&tracks=false', labelKey: 'dayplan.gpxDays', icon: RouteIcon },
|
||||
] as const
|
||||
|
||||
interface DayPlanSidebarToolbarProps {
|
||||
tripId: number
|
||||
trip: Trip
|
||||
@@ -33,8 +21,6 @@ interface DayPlanSidebarToolbarProps {
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
locale: string
|
||||
toast: ReturnType<typeof useToast>
|
||||
icsHover: boolean
|
||||
setIcsHover: (v: boolean) => void
|
||||
expandedDays: Set<number>
|
||||
setExpandedDays: (next: Set<number>) => void
|
||||
onUndo?: () => void
|
||||
@@ -44,11 +30,9 @@ interface DayPlanSidebarToolbarProps {
|
||||
lastActionLabel: string | null
|
||||
canEditDays?: boolean
|
||||
/**
|
||||
* Gates "Subscribe to calendar" only. The one-off "Download ICS" below it
|
||||
* stays open to every member: it is a file they already have the right to
|
||||
* read, while the subscription mints a link that works without an account.
|
||||
* Defaults to true so a caller that has not wired the permission through
|
||||
* keeps today's menu rather than silently losing an entry.
|
||||
* Gates "Subscribe to calendar" in the export dialog only. Defaults to true so
|
||||
* a caller that has not wired the permission through keeps today's entries
|
||||
* rather than silently losing one.
|
||||
*/
|
||||
canManageShare?: boolean
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
@@ -58,230 +42,53 @@ interface DayPlanSidebarToolbarProps {
|
||||
export function DayPlanSidebarToolbar({
|
||||
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
allConnectionsShown = false, onToggleAllConnections,
|
||||
t, locale, toast, setIcsHover,
|
||||
t, locale, toast,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
const icsMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [icsMenuVisible, setIcsMenuVisible] = useState(false)
|
||||
const gpxMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [gpxMenuVisible, setGpxMenuVisible] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
|
||||
const showIcsMenu = () => {
|
||||
if (icsMenuTimeoutRef.current) clearTimeout(icsMenuTimeoutRef.current)
|
||||
setIcsMenuVisible(true)
|
||||
setIcsHover(true)
|
||||
}
|
||||
const hideIcsMenu = () => {
|
||||
icsMenuTimeoutRef.current = setTimeout(() => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const showGpxMenu = () => {
|
||||
if (gpxMenuTimeoutRef.current) clearTimeout(gpxMenuTimeoutRef.current)
|
||||
setGpxMenuVisible(true)
|
||||
}
|
||||
const hideGpxMenu = () => {
|
||||
gpxMenuTimeoutRef.current = setTimeout(() => setGpxMenuVisible(false), 120)
|
||||
}
|
||||
|
||||
const downloadGpx = async (query: string) => {
|
||||
setGpxMenuVisible(false)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/places/export.gpx${query}`, { credentials: 'include' })
|
||||
// 404 here means the selection is empty, which is worth its own message:
|
||||
// "nothing happened" and "the download broke" look identical otherwise.
|
||||
if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return }
|
||||
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'}.gpx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error(t('dayplan.gpxFailed')) }
|
||||
}
|
||||
|
||||
const menuItemStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 7,
|
||||
width: '100%', padding: '7px 12px', border: 'none',
|
||||
background: 'transparent', cursor: 'pointer',
|
||||
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
|
||||
color: 'var(--text-primary)', textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}
|
||||
return (
|
||||
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<Tooltip label={t('dayplan.pdfTooltip')} placement="bottom">
|
||||
{/* One export button instead of three: PDF, ICS and GPX each carried
|
||||
their own hover menu, and on a narrower sidebar the row ran out of
|
||||
width and pushed them off the edge. The dialog holds every option. */}
|
||||
<Tooltip label={t('dayplan.exportIntro')} placement="bottom">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
||||
notes.map(n => ({ ...n, day_id: Number(dayId) }))
|
||||
)
|
||||
try {
|
||||
// Loaded on click: the PDF builder is ~226 kB and hangs off the
|
||||
// days sidebar, so every trip used to pay for it whether or not
|
||||
// anyone exported. A missing chunk lands in the catch below and
|
||||
// shows the same error the export already had.
|
||||
const { downloadTripPDF } = await import('../PDF/TripPDF')
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => setExportOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={exportOpen}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0,
|
||||
padding: '5px 10px', borderRadius: 8, border: 'none',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
{t('dayplan.pdf')}
|
||||
<Download size={13} strokeWidth={2} />
|
||||
{t('dayplan.export')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
style={{ position: 'relative', flexShrink: 0 }}
|
||||
onMouseEnter={showIcsMenu}
|
||||
onMouseLeave={hideIcsMenu}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
ICS
|
||||
</button>
|
||||
{icsMenuVisible && (
|
||||
<div
|
||||
onMouseEnter={showIcsMenu}
|
||||
onMouseLeave={hideIcsMenu}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 200, minWidth: 160,
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
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')) }
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<FileDown size={12} strokeWidth={2} />
|
||||
Download ICS
|
||||
</button>
|
||||
{canManageShare && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
setSubscribeOpen(true)
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<CalendarPlus size={12} strokeWidth={2} />
|
||||
Subscribe to calendar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* GPX — the counterpart to the GPX import in the places sidebar, sitting
|
||||
with the other exports rather than with the importer. Same hover-menu
|
||||
shape as ICS beside it. */}
|
||||
<div
|
||||
style={{ position: 'relative', flexShrink: 0 }}
|
||||
onMouseEnter={showGpxMenu}
|
||||
onMouseLeave={hideGpxMenu}
|
||||
>
|
||||
<button
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={gpxMenuVisible}
|
||||
aria-label={t('dayplan.gpxTooltip')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
GPX
|
||||
</button>
|
||||
{gpxMenuVisible && (
|
||||
<div
|
||||
role="menu"
|
||||
onMouseEnter={showGpxMenu}
|
||||
onMouseLeave={hideGpxMenu}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 200, minWidth: 190,
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{GPX_SCOPES.map(scope => (
|
||||
<button
|
||||
key={scope.key}
|
||||
role="menuitem"
|
||||
onClick={() => downloadGpx(scope.query)}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<scope.icon size={12} strokeWidth={2} />
|
||||
{t(scope.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{subscribeOpen && canManageShare && (
|
||||
<IcsSubscribeModal
|
||||
endpoint={`/api/trips/${tripId}/feed`}
|
||||
title="Subscribe to calendar"
|
||||
description="This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour."
|
||||
onClose={() => setSubscribeOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
<TripExportModal
|
||||
isOpen={exportOpen}
|
||||
onClose={() => setExportOpen(false)}
|
||||
tripId={tripId}
|
||||
trip={trip}
|
||||
days={days}
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
dayNotes={dayNotes}
|
||||
t={t}
|
||||
locale={locale}
|
||||
toast={toast}
|
||||
canManageShare={canManageShare}
|
||||
/>
|
||||
{(() => {
|
||||
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// FE-PLANNER-EXPORTMODAL-001 to FE-PLANNER-EXPORTMODAL-014
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { buildDay, buildDayNote, buildTrip } from '../../../tests/helpers/factories'
|
||||
import { TripExportModal } from './TripExportModal'
|
||||
|
||||
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }))
|
||||
|
||||
// The subscribe dialog fetches its feed token on mount; it is exercised in its
|
||||
// own test, here we only care that the entry mounts it.
|
||||
vi.mock('./IcsSubscribeModal', () => ({
|
||||
IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => (
|
||||
<div data-testid="ics-subscribe-modal">
|
||||
{title}
|
||||
<button onClick={onClose}>close-subscribe</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}|${Object.values(params).join('|')}` : key
|
||||
|
||||
const trip = buildTrip({ id: 1, title: 'Roadtrip' })
|
||||
|
||||
function makeToast() {
|
||||
return {
|
||||
success: vi.fn((_m: string) => {}),
|
||||
error: vi.fn((_m: string) => {}),
|
||||
warning: vi.fn((_m: string) => {}),
|
||||
info: vi.fn((_m: string) => {}),
|
||||
}
|
||||
}
|
||||
|
||||
function makeProps(overrides: Partial<React.ComponentProps<typeof TripExportModal>> = {}) {
|
||||
return {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
tripId: 1,
|
||||
trip,
|
||||
days: [],
|
||||
places: [],
|
||||
categories: [],
|
||||
assignments: {},
|
||||
reservations: [],
|
||||
dayNotes: {},
|
||||
t,
|
||||
locale: 'en-US',
|
||||
toast: makeToast(),
|
||||
...overrides,
|
||||
} as React.ComponentProps<typeof TripExportModal>
|
||||
}
|
||||
|
||||
let clickedHref: string | null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clickedHref = null
|
||||
// jsdom has neither of these, and an anchor click would navigate.
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
clickedHref = this.href
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const okResponse = (body = '<gpx/>') =>
|
||||
({ ok: true, status: 200, blob: async () => new Blob([body]) }) as unknown as Response
|
||||
|
||||
describe('TripExportModal', () => {
|
||||
it('FE-PLANNER-EXPORTMODAL-001: closed, it renders nothing', () => {
|
||||
render(<TripExportModal {...makeProps({ isOpen: false })} />)
|
||||
expect(screen.queryByText('dayplan.export')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-002: open, every export sits in its own section', () => {
|
||||
render(<TripExportModal {...makeProps()} />)
|
||||
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.exportMaps · GPX')).toBeInTheDocument()
|
||||
for (const label of ['dayplan.pdf', 'mobileTrip.icsDownload', 'mobileTrip.icsSubscribe',
|
||||
'dayplan.gpxAll', 'dayplan.gpxPlaces', 'dayplan.gpxDays']) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
// ── PDF ───────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-003: the PDF row exports the trip with the day notes flattened', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = [buildDay({ id: 10, title: 'Day 1' })]
|
||||
const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] }
|
||||
render(<TripExportModal {...makeProps({ days, dayNotes })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
|
||||
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-004: a finished PDF export closes the dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<TripExportModal {...makeProps({ onClose })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-005: a failing PDF export toasts the error and keeps the dialog open', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing'))
|
||||
const toast = makeToast()
|
||||
const onClose = vi.fn()
|
||||
render(<TripExportModal {...makeProps({ toast, onClose })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-006: an aborted PDF export without an Error still reaches the toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom')
|
||||
const toast = makeToast()
|
||||
render(<TripExportModal {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom'))
|
||||
})
|
||||
|
||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-007: the ICS row fetches the export and hands it to a download link', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.fn(async () => okResponse('BEGIN:VCALENDAR'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
render(<TripExportModal {...makeProps()} />)
|
||||
await user.click(screen.getByText('mobileTrip.icsDownload'))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' }))
|
||||
await waitFor(() => expect(clickedHref).toBe('blob:mock'))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-008: a rejected ICS export shows the failure toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
|
||||
const toast = makeToast()
|
||||
render(<TripExportModal {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('mobileTrip.icsDownload'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed'))
|
||||
expect(clickedHref).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-009: the subscribe row opens the subscribe dialog and closes again', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TripExportModal {...makeProps()} />)
|
||||
await user.click(screen.getByText('mobileTrip.icsSubscribe'))
|
||||
expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('close-subscribe'))
|
||||
expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The subscription mints a link that reads the trip without an account, so it
|
||||
// needs share_manage; the one-off download is a file this member may already
|
||||
// read. Leaving the entry visible would only produce a dialog whose enable
|
||||
// button the server refuses.
|
||||
it('FE-PLANNER-EXPORTMODAL-010: without share_manage the download stays and the subscription goes', () => {
|
||||
render(<TripExportModal {...makeProps({ canManageShare: false })} />)
|
||||
expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument()
|
||||
expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── GPX (#1442) ───────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-011: each scope asks the server for exactly its own selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.fn(async () => okResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
for (const [label, expected] of [
|
||||
['dayplan.gpxAll', '/api/trips/1/places/export.gpx'],
|
||||
['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'],
|
||||
['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'],
|
||||
] as const) {
|
||||
const view = render(<TripExportModal {...makeProps()} />)
|
||||
await user.click(screen.getByText(label))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' }))
|
||||
fetchMock.mockClear()
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-012: a download names the file after the trip', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => okResponse()))
|
||||
render(<TripExportModal {...makeProps()} />)
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(clickedHref).toBe('blob:mock'))
|
||||
await waitFor(() => expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-013: an empty trip says so instead of reporting a failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
const onClose = vi.fn()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response))
|
||||
render(<TripExportModal {...makeProps({ toast, onClose })} />)
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty'))
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
expect(clickedHref).toBeNull()
|
||||
// Nothing was downloaded, so the dialog stays put.
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-014: a real failure toasts the error', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
|
||||
render(<TripExportModal {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { CalendarDays, CalendarPlus, ChevronRight, Download, FileText, Loader2, MapPin, Route as RouteIcon } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { IcsSubscribeModal } from './IcsSubscribeModal'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
|
||||
/**
|
||||
* What a GPX download can carry. Worded by what someone wants on their device
|
||||
* rather than by the GPX element it maps to: "everything", "just the places" for
|
||||
* an offline map, "just the days" for following a plan. An omitted flag defaults
|
||||
* to true server-side, so the first entry needs no query at all.
|
||||
*/
|
||||
const GPX_SCOPES = [
|
||||
{ key: 'all', query: '', labelKey: 'dayplan.gpxAll', icon: Download },
|
||||
{ key: 'places', query: '?dayRoutes=false', labelKey: 'dayplan.gpxPlaces', icon: MapPin },
|
||||
{ key: 'days', query: '?waypoints=false&tracks=false', labelKey: 'dayplan.gpxDays', icon: RouteIcon },
|
||||
] as const
|
||||
|
||||
interface TripExportModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
tripId: number
|
||||
trip: Trip
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
reservations: Reservation[]
|
||||
dayNotes: Record<string, DayNote[]>
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
locale: string
|
||||
toast: ReturnType<typeof useToast>
|
||||
/**
|
||||
* Gates "Subscribe to calendar" only. The one-off ICS download above it stays
|
||||
* open to every member: it is a file they already have the right to read,
|
||||
* while the subscription mints a link that works without an account.
|
||||
*/
|
||||
canManageShare?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way a trip leaves TREK, in one dialog: the day plan as a PDF, the
|
||||
* bookings as a calendar (a one-off .ics or a live subscription) and the map
|
||||
* data as GPX in the three scopes a device actually wants.
|
||||
*
|
||||
* It replaces three separate toolbar buttons, two of which opened hover menus of
|
||||
* their own. On a narrow sidebar that row simply ran out of width and the
|
||||
* exports dropped off the edge; one button cannot.
|
||||
*/
|
||||
export function TripExportModal({
|
||||
isOpen, onClose, tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
t, locale, toast, canManageShare = true,
|
||||
}: TripExportModalProps) {
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
// Which row is working, so the dialog can say so instead of looking inert
|
||||
// while a 226 kB PDF builder is fetched and a document is rendered.
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const fileBase = trip?.title || 'trip'
|
||||
|
||||
// Shared tail of every download: Firefox and Safari cancel the download when
|
||||
// the object URL is revoked before they picked the blob up, hence the delay.
|
||||
const saveBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100)
|
||||
}
|
||||
|
||||
const exportPdf = async () => {
|
||||
if (busy) return
|
||||
setBusy('pdf')
|
||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
||||
notes.map(n => ({ ...n, day_id: Number(dayId) })),
|
||||
)
|
||||
try {
|
||||
// Loaded on click: the PDF builder is ~226 kB and hangs off the days
|
||||
// sidebar, so every trip used to pay for it whether or not anyone
|
||||
// exported. A missing chunk lands in the catch and shows the same error
|
||||
// the export already had.
|
||||
const { downloadTripPDF } = await import('../PDF/TripPDF')
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
|
||||
onClose()
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadIcs = async () => {
|
||||
if (busy) return
|
||||
setBusy('ics')
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error()
|
||||
saveBlob(await res.blob(), `${fileBase}.ics`)
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('planner.icsExportFailed'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadGpx = async (key: string, query: string) => {
|
||||
if (busy) return
|
||||
setBusy(`gpx:${key}`)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/places/export.gpx${query}`, { credentials: 'include' })
|
||||
// 404 here means the selection is empty, which is worth its own message:
|
||||
// "nothing happened" and "the download broke" look identical otherwise.
|
||||
if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return }
|
||||
if (!res.ok) throw new Error()
|
||||
saveBlob(await res.blob(), `${fileBase}.gpx`)
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('dayplan.gpxFailed'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
title={
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-accent-subtle text-accent-on">
|
||||
<Download size={16} strokeWidth={2} />
|
||||
</span>
|
||||
{t('dayplan.export')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<p className="-mt-1 mb-5 text-caption text-content-muted">{t('dayplan.exportIntro')}</p>
|
||||
|
||||
<Section label={t('dayplan.exportDocument')}>
|
||||
<ExportRow
|
||||
icon={FileText}
|
||||
title={t('dayplan.pdf')}
|
||||
sub={t('dayplan.pdfTooltip')}
|
||||
busy={busy === 'pdf'}
|
||||
disabled={busy != null}
|
||||
onClick={exportPdf}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section label={t('dayplan.exportCalendar')}>
|
||||
<ExportRow
|
||||
icon={CalendarDays}
|
||||
title={t('mobileTrip.icsDownload')}
|
||||
sub={`${fileBase}.ics`}
|
||||
busy={busy === 'ics'}
|
||||
disabled={busy != null}
|
||||
onClick={downloadIcs}
|
||||
/>
|
||||
{canManageShare && (
|
||||
<ExportRow
|
||||
icon={CalendarPlus}
|
||||
title={t('mobileTrip.icsSubscribe')}
|
||||
sub={t('mobileTrip.icsSubscribeSub')}
|
||||
disabled={busy != null}
|
||||
onClick={() => setSubscribeOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* GPX — the counterpart to the GPX import in the places sidebar. The
|
||||
format name carries more than a translated heading would, so it is
|
||||
spelled out beside the plain-language one. */}
|
||||
<Section label={`${t('dayplan.exportMaps')} · GPX`}>
|
||||
{GPX_SCOPES.map(scope => (
|
||||
<ExportRow
|
||||
key={scope.key}
|
||||
icon={scope.icon}
|
||||
title={t(scope.labelKey)}
|
||||
busy={busy === `gpx:${scope.key}`}
|
||||
disabled={busy != null}
|
||||
onClick={() => downloadGpx(scope.key, scope.query)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
</Modal>
|
||||
|
||||
{subscribeOpen && canManageShare && (
|
||||
<IcsSubscribeModal
|
||||
endpoint={`/api/trips/${tripId}/feed`}
|
||||
title={t('mobileTrip.icsSubscribe')}
|
||||
description={t('mobileTrip.icsSubscribeSub')}
|
||||
onClose={() => setSubscribeOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="mb-4 last:mb-0">
|
||||
<h3 className="mb-1.5 px-1 text-caption font-bold uppercase tracking-[0.07em] text-content-faint">{label}</h3>
|
||||
<div className="divide-y divide-edge-faint overflow-hidden rounded-xl border border-edge-faint bg-surface">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ExportRow({ icon: Icon, title, sub, busy = false, disabled = false, onClick }: {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
sub?: string
|
||||
busy?: boolean
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="group flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
|
||||
>
|
||||
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] bg-surface-tertiary text-content-secondary transition-colors group-enabled:group-hover:bg-accent-subtle group-enabled:group-hover:text-accent-on">
|
||||
{busy ? <Loader2 size={16} strokeWidth={2} className="animate-spin" /> : <Icon size={16} strokeWidth={1.9} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body font-semibold text-content">{title}</span>
|
||||
{sub && <span className="block truncate text-caption text-content-muted">{sub}</span>}
|
||||
</span>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
className="flex-none text-content-faint transition-transform group-enabled:group-hover:translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -67,6 +67,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pdfError': 'فشل تصدير PDF',
|
||||
'dayplan.expandAll': 'Expand all days', // en-fallback
|
||||
'dayplan.collapseAll': 'Collapse all days', // en-fallback
|
||||
'dayplan.export': 'تصدير',
|
||||
'dayplan.exportIntro': 'خذ خطتك معك: كمستند أو في تقويمك أو على جهاز GPS.',
|
||||
'dayplan.exportDocument': 'مستند',
|
||||
'dayplan.exportCalendar': 'التقويم',
|
||||
'dayplan.exportMaps': 'الخرائط وGPS',
|
||||
'dayplan.pdf': 'PDF', // en-fallback
|
||||
'dayplan.mobile.addPlace': 'Add Place', // en-fallback
|
||||
'dayplan.mobile.searchPlaces': 'Search places...', // en-fallback
|
||||
|
||||
@@ -49,6 +49,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Nenhum lugar com coordenadas para calcular a rota',
|
||||
'dayplan.confirmed': 'Confirmada',
|
||||
'dayplan.pendingRes': 'Pendente',
|
||||
'dayplan.export': 'Exportar',
|
||||
'dayplan.exportIntro': 'Leve seu plano com você: como documento, no calendário ou no GPS.',
|
||||
'dayplan.exportDocument': 'Documento',
|
||||
'dayplan.exportCalendar': 'Calendário',
|
||||
'dayplan.exportMaps': 'Mapas e GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exportar plano do dia em PDF',
|
||||
'dayplan.gpxTooltip': 'Exportar como GPX para mapas offline e aparelhos de GPS',
|
||||
|
||||
@@ -49,6 +49,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': "No s'han trobat llocs amb coordenades per calcular la ruta",
|
||||
'dayplan.confirmed': 'Confirmat',
|
||||
'dayplan.pendingRes': 'Pendent',
|
||||
'dayplan.export': 'Exporta',
|
||||
'dayplan.exportIntro': "Emporta't el pla: com a document, al calendari o al GPS.",
|
||||
'dayplan.exportDocument': 'Document',
|
||||
'dayplan.exportCalendar': 'Calendari',
|
||||
'dayplan.exportMaps': 'Mapes i GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exporta el pla diari com a PDF',
|
||||
'dayplan.gpxTooltip': 'Exporta com a GPX per a mapes fora de línia i dispositius GPS',
|
||||
|
||||
@@ -49,6 +49,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Nebyla nalezena žádná místa se souřadnicemi pro výpočet trasy',
|
||||
'dayplan.confirmed': 'Potvrzeno',
|
||||
'dayplan.pendingRes': 'Čeká na potvrzení',
|
||||
'dayplan.export': 'Exportovat',
|
||||
'dayplan.exportIntro': 'Vezmi si plán s sebou: jako dokument, do kalendáře nebo do GPS.',
|
||||
'dayplan.exportDocument': 'Dokument',
|
||||
'dayplan.exportCalendar': 'Kalendář',
|
||||
'dayplan.exportMaps': 'Mapy a GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exportovat denní plán do PDF',
|
||||
'dayplan.gpxTooltip': 'Exportovat jako GPX pro offline mapy a GPS zařízení',
|
||||
|
||||
@@ -60,6 +60,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Keine Orte mit Koordinaten für Routenberechnung gefunden',
|
||||
'dayplan.confirmed': 'Bestätigt',
|
||||
'dayplan.pendingRes': 'Ausstehend',
|
||||
'dayplan.export': 'Exportieren',
|
||||
'dayplan.exportIntro': 'Nimm deinen Plan mit: als Dokument, im Kalender oder auf dem GPS.',
|
||||
'dayplan.exportDocument': 'Dokument',
|
||||
'dayplan.exportCalendar': 'Kalender',
|
||||
'dayplan.exportMaps': 'Karten & GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Tagesplan als PDF exportieren',
|
||||
'dayplan.gpxTooltip': 'Als GPX für Offline-Karten und GPS-Geräte exportieren',
|
||||
|
||||
@@ -60,6 +60,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'No places with coordinates found for route calculation',
|
||||
'dayplan.confirmed': 'Confirmed',
|
||||
'dayplan.pendingRes': 'Pending',
|
||||
'dayplan.export': 'Export',
|
||||
'dayplan.exportIntro': 'Take your plan with you: as a document, in your calendar or on your GPS.',
|
||||
'dayplan.exportDocument': 'Document',
|
||||
'dayplan.exportCalendar': 'Calendar',
|
||||
'dayplan.exportMaps': 'Maps & GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Export day plan as PDF',
|
||||
'dayplan.gpxTooltip': 'Export as GPX for offline maps and GPS devices',
|
||||
|
||||
@@ -49,6 +49,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'No se encontraron lugares con coordenadas para calcular la ruta',
|
||||
'dayplan.confirmed': 'Confirmado',
|
||||
'dayplan.pendingRes': 'Pendiente',
|
||||
'dayplan.export': 'Exportar',
|
||||
'dayplan.exportIntro': 'Llévate tu plan: como documento, en el calendario o en el GPS.',
|
||||
'dayplan.exportDocument': 'Documento',
|
||||
'dayplan.exportCalendar': 'Calendario',
|
||||
'dayplan.exportMaps': 'Mapas y GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exportar plan diario como PDF',
|
||||
'dayplan.gpxTooltip': 'Exportar como GPX para mapas sin conexión y dispositivos GPS',
|
||||
|
||||
@@ -49,6 +49,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': "Aucun lieu avec des coordonnées trouvé pour le calcul d'itinéraire",
|
||||
'dayplan.confirmed': 'Confirmé',
|
||||
'dayplan.pendingRes': 'En attente',
|
||||
'dayplan.export': 'Exporter',
|
||||
'dayplan.exportIntro': 'Emportez votre plan : en document, dans votre agenda ou sur votre GPS.',
|
||||
'dayplan.exportDocument': 'Document',
|
||||
'dayplan.exportCalendar': 'Agenda',
|
||||
'dayplan.exportMaps': 'Cartes et GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exporter le plan du jour en PDF',
|
||||
'dayplan.gpxTooltip': 'Exporter en GPX pour les cartes hors ligne et les GPS',
|
||||
|
||||
@@ -60,6 +60,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Δεν βρέθηκαν μέρη με συντεταγμένες για τον υπολογισμό διαδρομής',
|
||||
'dayplan.confirmed': 'Επιβεβαιωμένο',
|
||||
'dayplan.pendingRes': 'Εκκρεμές',
|
||||
'dayplan.export': 'Εξαγωγή',
|
||||
'dayplan.exportIntro': 'Πάρε το πλάνο σου μαζί: ως έγγραφο, στο ημερολόγιο ή στο GPS.',
|
||||
'dayplan.exportDocument': 'Έγγραφο',
|
||||
'dayplan.exportCalendar': 'Ημερολόγιο',
|
||||
'dayplan.exportMaps': 'Χάρτες και GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Εξαγωγή ημερήσιου πλάνου ως PDF',
|
||||
'dayplan.gpxTooltip': 'Εξαγωγή ως GPX για χάρτες εκτός σύνδεσης και συσκευές GPS',
|
||||
|
||||
@@ -49,6 +49,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Nem találhatók koordinátákkal rendelkező helyek az útvonalszámításhoz',
|
||||
'dayplan.confirmed': 'Megerősítve',
|
||||
'dayplan.pendingRes': 'Függőben',
|
||||
'dayplan.export': 'Exportálás',
|
||||
'dayplan.exportIntro': 'Vidd magaddal a tervet: dokumentumként, a naptáradban vagy a GPS-eden.',
|
||||
'dayplan.exportDocument': 'Dokumentum',
|
||||
'dayplan.exportCalendar': 'Naptár',
|
||||
'dayplan.exportMaps': 'Térképek és GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Napi terv exportálása PDF-be',
|
||||
'dayplan.gpxTooltip': 'Exportálás GPX-ként offline térképekhez és GPS-eszközökhöz',
|
||||
|
||||
@@ -58,6 +58,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Tidak ditemukan tempat dengan koordinat untuk kalkulasi rute',
|
||||
'dayplan.confirmed': 'Dikonfirmasi',
|
||||
'dayplan.pendingRes': 'Menunggu',
|
||||
'dayplan.export': 'Ekspor',
|
||||
'dayplan.exportIntro': 'Bawa rencanamu: sebagai dokumen, di kalender, atau di GPS.',
|
||||
'dayplan.exportDocument': 'Dokumen',
|
||||
'dayplan.exportCalendar': 'Kalender',
|
||||
'dayplan.exportMaps': 'Peta & GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Ekspor rencana hari sebagai PDF',
|
||||
'dayplan.gpxTooltip': 'Ekspor sebagai GPX untuk peta offline dan perangkat GPS',
|
||||
|
||||
@@ -49,6 +49,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Nessun luogo con coordinate trovato per il calcolo del percorso',
|
||||
'dayplan.confirmed': 'Confermata',
|
||||
'dayplan.pendingRes': 'In attesa',
|
||||
'dayplan.export': 'Esporta',
|
||||
'dayplan.exportIntro': 'Porta con te il tuo piano: come documento, nel calendario o sul GPS.',
|
||||
'dayplan.exportDocument': 'Documento',
|
||||
'dayplan.exportCalendar': 'Calendario',
|
||||
'dayplan.exportMaps': 'Mappe e GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Esporta il programma del giorno come PDF',
|
||||
'dayplan.gpxTooltip': 'Esporta in GPX per mappe offline e dispositivi GPS',
|
||||
|
||||
@@ -60,6 +60,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': '座標付きの場所がありません',
|
||||
'dayplan.confirmed': '確定',
|
||||
'dayplan.pendingRes': '保留',
|
||||
'dayplan.export': 'エクスポート',
|
||||
'dayplan.exportIntro': '旅程を持ち歩けます。書類として、カレンダーに、GPS 機器に。',
|
||||
'dayplan.exportDocument': '書類',
|
||||
'dayplan.exportCalendar': 'カレンダー',
|
||||
'dayplan.exportMaps': '地図と GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': '日別計画をPDFで書き出し',
|
||||
'dayplan.gpxTooltip': 'オフライン地図やGPS機器向けにGPXで書き出す',
|
||||
|
||||
@@ -60,6 +60,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': '경로 계산을 위한 좌표가 있는 장소가 없습니다',
|
||||
'dayplan.confirmed': '확정됨',
|
||||
'dayplan.pendingRes': '대기 중',
|
||||
'dayplan.export': '내보내기',
|
||||
'dayplan.exportIntro': '일정을 가지고 다니세요. 문서로, 캘린더에, GPS 기기에.',
|
||||
'dayplan.exportDocument': '문서',
|
||||
'dayplan.exportCalendar': '캘린더',
|
||||
'dayplan.exportMaps': '지도 및 GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': '일별 계획을 PDF로 내보내기',
|
||||
'dayplan.gpxTooltip': '오프라인 지도와 GPS 기기용 GPX로 내보내기',
|
||||
|
||||
@@ -49,6 +49,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Geen plaatsen met coördinaten gevonden voor routeberekening',
|
||||
'dayplan.confirmed': 'Bevestigd',
|
||||
'dayplan.pendingRes': 'In behandeling',
|
||||
'dayplan.export': 'Exporteren',
|
||||
'dayplan.exportIntro': 'Neem je plan mee: als document, in je agenda of op je gps.',
|
||||
'dayplan.exportDocument': 'Document',
|
||||
'dayplan.exportCalendar': 'Agenda',
|
||||
'dayplan.exportMaps': 'Kaarten en gps',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Dagplan exporteren als PDF',
|
||||
'dayplan.gpxTooltip': 'Exporteren als GPX voor offline kaarten en gps-apparaten',
|
||||
|
||||
@@ -59,6 +59,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Nie znaleziono miejsc ze współrzędnymi do obliczenia trasy',
|
||||
'dayplan.confirmed': 'Potwierdzono',
|
||||
'dayplan.pendingRes': 'Oczekujące',
|
||||
'dayplan.export': 'Eksportuj',
|
||||
'dayplan.exportIntro': 'Zabierz plan ze sobą: jako dokument, do kalendarza albo na GPS.',
|
||||
'dayplan.exportDocument': 'Dokument',
|
||||
'dayplan.exportCalendar': 'Kalendarz',
|
||||
'dayplan.exportMaps': 'Mapy i GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Eksportuj plan dnia jako PDF',
|
||||
'dayplan.gpxTooltip': 'Eksportuj jako GPX do map offline i urządzeń GPS',
|
||||
|
||||
@@ -49,6 +49,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Не найдено мест с координатами для расчёта маршрута',
|
||||
'dayplan.confirmed': 'Подтверждено',
|
||||
'dayplan.pendingRes': 'Ожидание',
|
||||
'dayplan.export': 'Экспорт',
|
||||
'dayplan.exportIntro': 'Возьмите план с собой: документом, в календарь или на GPS.',
|
||||
'dayplan.exportDocument': 'Документ',
|
||||
'dayplan.exportCalendar': 'Календарь',
|
||||
'dayplan.exportMaps': 'Карты и GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Экспортировать план дня в PDF',
|
||||
'dayplan.gpxTooltip': 'Экспорт в GPX для офлайн-карт и GPS-устройств',
|
||||
|
||||
@@ -61,6 +61,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Inga platser med koordinater hittades för ruttberäkningen',
|
||||
'dayplan.confirmed': 'Bekräftat',
|
||||
'dayplan.pendingRes': 'Pendlande',
|
||||
'dayplan.export': 'Exportera',
|
||||
'dayplan.exportIntro': 'Ta med dig planen: som dokument, i kalendern eller på GPS:en.',
|
||||
'dayplan.exportDocument': 'Dokument',
|
||||
'dayplan.exportCalendar': 'Kalender',
|
||||
'dayplan.exportMaps': 'Kartor och GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exportera dagsplanen som PDF',
|
||||
'dayplan.gpxTooltip': 'Exportera som GPX för offlinekartor och GPS-enheter',
|
||||
|
||||
@@ -60,6 +60,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Rota için koordinatlı yer bulunamadı',
|
||||
'dayplan.confirmed': 'Onaylandı',
|
||||
'dayplan.pendingRes': 'Beklemede',
|
||||
'dayplan.export': 'Dışa aktar',
|
||||
'dayplan.exportIntro': 'Planını yanında taşı: belge olarak, takviminde ya da GPS cihazında.',
|
||||
'dayplan.exportDocument': 'Belge',
|
||||
'dayplan.exportCalendar': 'Takvim',
|
||||
'dayplan.exportMaps': 'Haritalar ve GPS',
|
||||
'dayplan.pdf': "PDF'ler",
|
||||
'dayplan.pdfTooltip': 'Gün planını PDF olarak dışa aktar',
|
||||
'dayplan.gpxTooltip': 'Çevrimdışı haritalar ve GPS cihazları için GPX olarak dışa aktar',
|
||||
|
||||
@@ -51,6 +51,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Не знайдено місць з координатами для розрахунку маршруту',
|
||||
'dayplan.confirmed': 'Подтверждено',
|
||||
'dayplan.pendingRes': 'Ожидание',
|
||||
'dayplan.export': 'Експорт',
|
||||
'dayplan.exportIntro': 'Візьміть план із собою: документом, у календар або на GPS.',
|
||||
'dayplan.exportDocument': 'Документ',
|
||||
'dayplan.exportCalendar': 'Календар',
|
||||
'dayplan.exportMaps': 'Карти та GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Експортувати план дня в PDF',
|
||||
'dayplan.gpxTooltip': 'Експорт у GPX для офлайн-карт і GPS-пристроїв',
|
||||
|
||||
@@ -60,6 +60,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': 'Không tìm thấy địa điểm nào có tọa độ để tính toán tuyến đường',
|
||||
'dayplan.confirmed': 'Đã xác nhận',
|
||||
'dayplan.pendingRes': 'Chưa giải quyết',
|
||||
'dayplan.export': 'Xuất',
|
||||
'dayplan.exportIntro': 'Mang kế hoạch theo bên mình: dưới dạng tài liệu, trong lịch hoặc trên GPS.',
|
||||
'dayplan.exportDocument': 'Tài liệu',
|
||||
'dayplan.exportCalendar': 'Lịch',
|
||||
'dayplan.exportMaps': 'Bản đồ và GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Kế hoạch ngày xuất dưới dạng PDF',
|
||||
'dayplan.gpxTooltip': 'Xuất ra GPX cho bản đồ ngoại tuyến và thiết bị GPS',
|
||||
|
||||
@@ -49,6 +49,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': '未找到有座標的地點用於路線計算',
|
||||
'dayplan.confirmed': '已確認',
|
||||
'dayplan.pendingRes': '待確認',
|
||||
'dayplan.export': '匯出',
|
||||
'dayplan.exportIntro': '把行程帶在身邊:作為文件、放進行事曆,或存到 GPS 裝置。',
|
||||
'dayplan.exportDocument': '文件',
|
||||
'dayplan.exportCalendar': '行事曆',
|
||||
'dayplan.exportMaps': '地圖與 GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': '匯出當天計劃為 PDF',
|
||||
'dayplan.gpxTooltip': '匯出為 GPX,用於離線地圖和 GPS 裝置',
|
||||
|
||||
@@ -49,6 +49,11 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.toast.noGeoPlaces': '未找到有坐标的地点用于路线计算',
|
||||
'dayplan.confirmed': '已确认',
|
||||
'dayplan.pendingRes': '待确认',
|
||||
'dayplan.export': '导出',
|
||||
'dayplan.exportIntro': '把行程带在身边:作为文档、放进日历,或存到 GPS 设备。',
|
||||
'dayplan.exportDocument': '文档',
|
||||
'dayplan.exportCalendar': '日历',
|
||||
'dayplan.exportMaps': '地图与 GPS',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': '导出当天计划为 PDF',
|
||||
'dayplan.gpxTooltip': '导出为 GPX,用于离线地图和 GPS 设备',
|
||||
|
||||
Reference in New Issue
Block a user