mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(planner): share the trip export between desktop and phone
The export dialog and the phone export sheet keep their rows and run the PDF, the calendar file and the GPX download through useTripExport, which also reads the print settings and whether my plan is offered. The desktop still runs one export at a time and closes after each one; the phone still lets a calendar or GPX file run beside a PDF and stays open after the PDF.
This commit is contained in:
@@ -6,11 +6,7 @@ import { IcsSubscribeModal } from './IcsSubscribeModal'
|
||||
import { SoftPill } from './planParts'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { hasPersonalPlan } from '../PDF/pdfScope'
|
||||
import { importChunk } from '../../utils/chunkReload'
|
||||
import { useTripExport } from './useTripExport'
|
||||
|
||||
/**
|
||||
* What a GPX download can carry. Worded by what someone wants on their device
|
||||
@@ -60,88 +56,21 @@ export function TripExportModal({
|
||||
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)
|
||||
// The PDF is built outside React, so it cannot read this itself (#2066).
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
// The export gets the store's assignments and applies the day plan's own filter to
|
||||
// them, so it needs the same switch the plan reads.
|
||||
const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, tripId)
|
||||
const titleId = useId()
|
||||
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 myId = useAuthStore(s => s.user?.id)
|
||||
// "My plan" (#2168) only once somebody has been given a part of the trip.
|
||||
const offerMine = myId != null && hasPersonalPlan(assignments, reservations)
|
||||
|
||||
const exportPdf = async (mine = false) => {
|
||||
if (busy) return
|
||||
setBusy(mine ? 'pdf:mine' : '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 importChunk(() => import('../PDF/TripPDF'))
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, timeFormat, distanceUnit, showServiceStops, onlyUserId: mine ? myId : undefined })
|
||||
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)
|
||||
}
|
||||
}
|
||||
const {
|
||||
fileBase, offerMine, isRunning, anyRunning, exportPdf, downloadIcs, downloadGpx,
|
||||
} = useTripExport({
|
||||
tripId,
|
||||
data: { trip, days, places, assignments, categories, reservations, dayNotes },
|
||||
t,
|
||||
locale,
|
||||
toast,
|
||||
exclusive: true,
|
||||
onExported: onClose,
|
||||
requireTrip: false,
|
||||
closeAfterPdf: true,
|
||||
logPdfErrors: true,
|
||||
})
|
||||
|
||||
const header = (
|
||||
<DialogHeader
|
||||
@@ -166,8 +95,8 @@ export function TripExportModal({
|
||||
icon={FileText}
|
||||
title={t('dayplan.pdf')}
|
||||
sub={t('dayplan.pdfTooltip')}
|
||||
busy={busy === 'pdf'}
|
||||
disabled={busy != null}
|
||||
busy={isRunning('pdf')}
|
||||
disabled={anyRunning}
|
||||
onClick={() => exportPdf()}
|
||||
/>
|
||||
{offerMine && (
|
||||
@@ -175,8 +104,8 @@ export function TripExportModal({
|
||||
icon={UserRound}
|
||||
title={t('dayplan.pdfMine')}
|
||||
sub={t('dayplan.pdfMineSub')}
|
||||
busy={busy === 'pdf:mine'}
|
||||
disabled={busy != null}
|
||||
busy={isRunning('pdf:mine')}
|
||||
disabled={anyRunning}
|
||||
onClick={() => exportPdf(true)}
|
||||
/>
|
||||
)}
|
||||
@@ -189,8 +118,8 @@ export function TripExportModal({
|
||||
icon={CalendarDays}
|
||||
title={t('mobileTrip.icsDownload')}
|
||||
sub={`${fileBase}.ics`}
|
||||
busy={busy === 'ics'}
|
||||
disabled={busy != null}
|
||||
busy={isRunning('ics')}
|
||||
disabled={anyRunning}
|
||||
onClick={downloadIcs}
|
||||
/>
|
||||
{canManageShare && (
|
||||
@@ -198,7 +127,7 @@ export function TripExportModal({
|
||||
icon={CalendarPlus}
|
||||
title={t('mobileTrip.icsSubscribe')}
|
||||
sub={t('mobileTrip.icsSubscribeSub')}
|
||||
disabled={busy != null}
|
||||
disabled={anyRunning}
|
||||
onClick={() => setSubscribeOpen(true)}
|
||||
/>
|
||||
)}
|
||||
@@ -215,8 +144,8 @@ export function TripExportModal({
|
||||
key={scope.key}
|
||||
icon={scope.icon}
|
||||
title={t(scope.labelKey)}
|
||||
busy={busy === `gpx:${scope.key}`}
|
||||
disabled={busy != null}
|
||||
busy={isRunning(`gpx:${scope.key}`)}
|
||||
disabled={anyRunning}
|
||||
onClick={() => downloadGpx(scope.key, scope.query)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// FE-PLANNER-TRIPEXPORT-001 to -010: the trip exports behind the desktop export
|
||||
// dialog and the phone's export sheet.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
|
||||
import { buildAssignment, buildReservation, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { DayNote } from '../../types';
|
||||
import { downloadTripPDF } from '../PDF/TripPDF';
|
||||
import { flatDayNotes, saveBlob, useTripExport, type TripExportOptions } from './useTripExport';
|
||||
|
||||
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn() }));
|
||||
|
||||
const t = (key: string) => key;
|
||||
const trip = buildTrip({ id: 3, title: 'Alps' });
|
||||
const NOTES = { '7': [{ id: 1, text: 'Pack' }] as unknown as DayNote[] };
|
||||
|
||||
function options(overrides: Partial<TripExportOptions> = {}): TripExportOptions {
|
||||
return {
|
||||
tripId: 3,
|
||||
data: { trip, days: [], places: [], assignments: {}, categories: [], reservations: [], dayNotes: NOTES },
|
||||
t,
|
||||
locale: 'en-US',
|
||||
toast: { error: vi.fn(), info: vi.fn() },
|
||||
exclusive: true,
|
||||
onExported: vi.fn(),
|
||||
requireTrip: false,
|
||||
closeAfterPdf: true,
|
||||
logPdfErrors: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** How the phone sheet runs the hook. */
|
||||
const PHONE: Partial<TripExportOptions> = {
|
||||
exclusive: false,
|
||||
requireTrip: true,
|
||||
closeAfterPdf: false,
|
||||
logPdfErrors: false,
|
||||
};
|
||||
|
||||
const response = (status: number) =>
|
||||
({ ok: status >= 200 && status < 300, status, blob: async () => new Blob(['x']) }) as unknown as Response;
|
||||
|
||||
/** A promise the test settles by hand, to look at the hook while an export runs. */
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let clickSpy: MockInstance<() => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.mocked(downloadTripPDF).mockReset().mockResolvedValue(undefined);
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response(200));
|
||||
clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:trek/file');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('flatDayNotes and saveBlob', () => {
|
||||
it('FE-PLANNER-TRIPEXPORT-001: the day notes come as one list that names each day', () => {
|
||||
expect(flatDayNotes({ '2': [{ id: 1 }, { id: 2 }] as DayNote[], '5': [{ id: 3 }] as DayNote[] })).toEqual([
|
||||
{ id: 1, day_id: 2 },
|
||||
{ id: 2, day_id: 2 },
|
||||
{ id: 3, day_id: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRIPEXPORT-002: a file is handed over under its name and its URL let go a moment later', () => {
|
||||
vi.useFakeTimers();
|
||||
saveBlob(new Blob(['x']), 'Alps.ics');
|
||||
const anchor = clickSpy.mock.contexts[0] as HTMLAnchorElement;
|
||||
expect(anchor.download).toBe('Alps.ics');
|
||||
expect(anchor.getAttribute('href')).toBe('blob:trek/file');
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:trek/file');
|
||||
expect(document.body.contains(anchor)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTripExport', () => {
|
||||
it('FE-PLANNER-TRIPEXPORT-003: names the files after the trip and offers my plan once somebody has a part', () => {
|
||||
expect(renderHook(() => useTripExport(options())).result.current).toMatchObject({
|
||||
fileBase: 'Alps',
|
||||
offerMine: false,
|
||||
});
|
||||
const untitled = options({ data: { ...options().data, trip: null } });
|
||||
expect(renderHook(() => useTripExport(untitled)).result.current.fileBase).toBe('trip');
|
||||
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 9 }) });
|
||||
const shared = options({
|
||||
data: { ...options().data, reservations: [buildReservation({ travelers: [{ user_id: 9, username: 'me' }] })] },
|
||||
});
|
||||
expect(renderHook(() => useTripExport(shared)).result.current.offerMine).toBe(true);
|
||||
const assigned = options({
|
||||
data: {
|
||||
...options().data,
|
||||
assignments: { '1': [buildAssignment({ participants: [{ user_id: 9, username: 'me' }] })] },
|
||||
},
|
||||
});
|
||||
expect(renderHook(() => useTripExport(assigned)).result.current.offerMine).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRIPEXPORT-004: the PDF gets the whole plan, or only mine, and the desktop closes once it is out', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 9 }) });
|
||||
const opts = options();
|
||||
const { result } = renderHook(() => useTripExport(opts));
|
||||
await act(() => result.current.exportPdf());
|
||||
expect(downloadTripPDF).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trip,
|
||||
dayNotes: [{ id: 1, text: 'Pack', day_id: 7 }],
|
||||
locale: 'en-US',
|
||||
onlyUserId: undefined,
|
||||
})
|
||||
);
|
||||
expect(opts.onExported).toHaveBeenCalledTimes(1);
|
||||
await act(() => result.current.exportPdf(true));
|
||||
expect(vi.mocked(downloadTripPDF).mock.calls[1][0]).toMatchObject({ onlyUserId: 9 });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRIPEXPORT-005: the phone stays open after the PDF and prints nothing without a trip', async () => {
|
||||
const opts = options(PHONE);
|
||||
const { result } = renderHook(() => useTripExport(opts));
|
||||
await act(() => result.current.exportPdf());
|
||||
expect(downloadTripPDF).toHaveBeenCalledTimes(1);
|
||||
expect(opts.onExported).not.toHaveBeenCalled();
|
||||
|
||||
const noTrip = renderHook(() => useTripExport({ ...opts, data: { ...opts.data, trip: null } })).result;
|
||||
await act(() => noTrip.current.exportPdf());
|
||||
expect(downloadTripPDF).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRIPEXPORT-006: a failed PDF says why; only the desktop also logs it', async () => {
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(downloadTripPDF).mockRejectedValue(new Error('font missing'));
|
||||
const desktop = options();
|
||||
const d = renderHook(() => useTripExport(desktop)).result;
|
||||
await act(() => d.current.exportPdf());
|
||||
expect(desktop.toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing');
|
||||
expect(desktop.onExported).not.toHaveBeenCalled();
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(downloadTripPDF).mockRejectedValue('renderer gone');
|
||||
const phone = options(PHONE);
|
||||
const p = renderHook(() => useTripExport(phone)).result;
|
||||
await act(() => p.current.exportPdf());
|
||||
expect(phone.toast.error).toHaveBeenCalledWith('dayplan.pdfError: renderer gone');
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRIPEXPORT-007: on the desktop one running export holds every other', async () => {
|
||||
const pdf = deferred();
|
||||
vi.mocked(downloadTripPDF).mockReturnValue(pdf.promise);
|
||||
const { result } = renderHook(() => useTripExport(options()));
|
||||
let running!: Promise<void>;
|
||||
act(() => {
|
||||
running = result.current.exportPdf();
|
||||
});
|
||||
await vi.waitFor(() => expect(downloadTripPDF).toHaveBeenCalled());
|
||||
expect(result.current.isRunning('pdf')).toBe(true);
|
||||
expect(result.current.isRunning('pdf:mine')).toBe(false);
|
||||
expect(result.current.anyRunning).toBe(true);
|
||||
await act(() => result.current.downloadIcs());
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
pdf.resolve();
|
||||
await running;
|
||||
});
|
||||
expect(result.current.anyRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRIPEXPORT-008: on the phone a running PDF holds only another PDF', async () => {
|
||||
const pdf = deferred();
|
||||
vi.mocked(downloadTripPDF).mockReturnValue(pdf.promise);
|
||||
const opts = options(PHONE);
|
||||
const { result } = renderHook(() => useTripExport(opts));
|
||||
let running!: Promise<void>;
|
||||
act(() => {
|
||||
running = result.current.exportPdf();
|
||||
});
|
||||
await vi.waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1));
|
||||
await act(() => result.current.exportPdf(true));
|
||||
expect(downloadTripPDF).toHaveBeenCalledTimes(1);
|
||||
await act(() => result.current.downloadIcs());
|
||||
expect(fetchSpy).toHaveBeenCalledWith('/api/trips/3/export.ics', { credentials: 'include' });
|
||||
expect(opts.onExported).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.isRunning('pdf')).toBe(true);
|
||||
await act(async () => {
|
||||
pdf.resolve();
|
||||
await running;
|
||||
});
|
||||
expect(result.current.isRunning('pdf')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRIPEXPORT-009: the calendar file is saved under the trip title, and a refusal says so', async () => {
|
||||
const opts = options();
|
||||
const { result } = renderHook(() => useTripExport(opts));
|
||||
await act(() => result.current.downloadIcs());
|
||||
expect((clickSpy.mock.contexts[0] as HTMLAnchorElement).download).toBe('Alps.ics');
|
||||
expect(opts.onExported).toHaveBeenCalledTimes(1);
|
||||
|
||||
fetchSpy.mockResolvedValue(response(500));
|
||||
await act(() => result.current.downloadIcs());
|
||||
expect(opts.toast.error).toHaveBeenCalledWith('planner.icsExportFailed');
|
||||
expect(opts.onExported).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRIPEXPORT-010: a GPX scope asks for its own query; an empty one and a broken one say so apart', async () => {
|
||||
const opts = options();
|
||||
const { result } = renderHook(() => useTripExport(opts));
|
||||
await act(() => result.current.downloadGpx('places', '?dayRoutes=false'));
|
||||
expect(fetchSpy).toHaveBeenCalledWith('/api/trips/3/places/export.gpx?dayRoutes=false', { credentials: 'include' });
|
||||
expect((clickSpy.mock.contexts[0] as HTMLAnchorElement).download).toBe('Alps.gpx');
|
||||
expect(opts.onExported).toHaveBeenCalledTimes(1);
|
||||
|
||||
fetchSpy.mockResolvedValue(response(404));
|
||||
await act(() => result.current.downloadGpx('all', ''));
|
||||
expect(opts.toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty');
|
||||
expect(opts.toast.error).not.toHaveBeenCalled();
|
||||
|
||||
fetchSpy.mockRejectedValue(new Error('offline'));
|
||||
await act(() => result.current.downloadGpx('all', ''));
|
||||
expect(opts.toast.error).toHaveBeenCalledWith('dayplan.gpxFailed');
|
||||
expect(opts.onExported).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.anyRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { AssignmentsMap, Category, Day, DayNote, Place, Reservation, Trip } from '../../types';
|
||||
import { importChunk } from '../../utils/chunkReload';
|
||||
import { hasPersonalPlan } from '../PDF/pdfScope';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/** A running export: a PDF of the plan or of my plan, the calendar file, or a GPX scope. */
|
||||
export type ExportJob = 'pdf' | 'pdf:mine' | 'ics' | `gpx:${string}`;
|
||||
|
||||
/** What a trip export prints, as both shells hold it. */
|
||||
export interface TripExportData {
|
||||
trip: Trip | null;
|
||||
days: Day[];
|
||||
places: Place[];
|
||||
assignments: AssignmentsMap;
|
||||
categories: Category[];
|
||||
reservations: Reservation[];
|
||||
dayNotes: Record<string, DayNote[]>;
|
||||
}
|
||||
|
||||
export interface TripExportOptions {
|
||||
tripId: number;
|
||||
data: TripExportData;
|
||||
t: Translate;
|
||||
locale: string;
|
||||
toast: { error: (message: string) => void; info: (message: string) => void };
|
||||
/**
|
||||
* The desktop runs one export at a time, so any running one holds every row; the
|
||||
* phone holds back only another PDF, calendar file or GPX file while the same kind
|
||||
* still runs.
|
||||
*/
|
||||
exclusive: boolean;
|
||||
/** Runs once a calendar or GPX file is saved, and once the PDF is out when closeAfterPdf is set. */
|
||||
onExported: () => void;
|
||||
/** The phone prints no PDF until the trip has loaded; the desktop hands the PDF builder what it has. */
|
||||
requireTrip: boolean;
|
||||
/** The desktop closes after the PDF as well; the phone sheet stays open. */
|
||||
closeAfterPdf: boolean;
|
||||
/** The desktop also logs a failed PDF to the console. */
|
||||
logPdfErrors: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the browser a downloaded file. Firefox and Safari cancel the download when
|
||||
* the object URL is revoked before they picked the blob up, hence the delay.
|
||||
*/
|
||||
export function 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);
|
||||
}
|
||||
|
||||
/** The day notes as one list, each carrying the day it belongs to. */
|
||||
export function flatDayNotes(dayNotes: Record<string, DayNote[]>) {
|
||||
return Object.entries(dayNotes).flatMap(([dayId, notes]) => notes.map((n) => ({ ...n, day_id: Number(dayId) })));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way a trip leaves TREK, behind the desktop export dialog and the phone's
|
||||
* export sheet: the day plan as a PDF (the whole plan, or my plan once somebody
|
||||
* has been given a part of the trip, #2168), the bookings as an .ics file and the
|
||||
* map data as GPX.
|
||||
*/
|
||||
export function useTripExport({
|
||||
tripId,
|
||||
data,
|
||||
t,
|
||||
locale,
|
||||
toast,
|
||||
exclusive,
|
||||
onExported,
|
||||
requireTrip,
|
||||
closeAfterPdf,
|
||||
logPdfErrors,
|
||||
}: TripExportOptions) {
|
||||
// Which export is working, per lane, so a row can say so instead of looking inert
|
||||
// while a 226 kB PDF builder is fetched and a document is rendered.
|
||||
const [running, setRunning] = useState<Record<string, ExportJob>>({});
|
||||
// The PDF is built outside React, so it cannot read these itself (#2066).
|
||||
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h';
|
||||
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit);
|
||||
// The export gets the store's assignments and applies the day plan's own filter to
|
||||
// them, so it needs the same switch the plan reads.
|
||||
const showServiceStops = useRoadtripSettings((s) => s.roadtrip_service_stops_in_days !== false, tripId);
|
||||
const myId = useAuthStore((s) => s.user?.id);
|
||||
const offerMine = myId != null && hasPersonalPlan(data.assignments, data.reservations);
|
||||
const fileBase = data.trip?.title || 'trip';
|
||||
|
||||
const laneOf = (job: ExportJob) => (exclusive ? 'all' : job.split(':')[0]);
|
||||
const isRunning = (job: ExportJob) => running[laneOf(job)] === job;
|
||||
const begin = (job: ExportJob) => setRunning((prev) => ({ ...prev, [laneOf(job)]: job }));
|
||||
const end = (job: ExportJob) =>
|
||||
setRunning((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[laneOf(job)];
|
||||
return next;
|
||||
});
|
||||
|
||||
const exportPdf = async (mine = false) => {
|
||||
const job: ExportJob = mine ? 'pdf:mine' : 'pdf';
|
||||
if ((requireTrip && !data.trip) || running[laneOf(job)]) return;
|
||||
begin(job);
|
||||
const flatNotes = flatDayNotes(data.dayNotes);
|
||||
try {
|
||||
// Loaded on click: the PDF builder is ~226 kB, 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 importChunk(() => import('../PDF/TripPDF'));
|
||||
await downloadTripPDF({
|
||||
trip: data.trip,
|
||||
days: data.days,
|
||||
places: data.places,
|
||||
assignments: data.assignments,
|
||||
categories: data.categories,
|
||||
dayNotes: flatNotes,
|
||||
reservations: data.reservations,
|
||||
t,
|
||||
locale,
|
||||
timeFormat,
|
||||
distanceUnit,
|
||||
showServiceStops,
|
||||
onlyUserId: mine ? myId : undefined,
|
||||
});
|
||||
if (closeAfterPdf) onExported();
|
||||
} catch (e) {
|
||||
if (logPdfErrors) console.error('PDF error:', e);
|
||||
toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
end(job);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadIcs = async () => {
|
||||
const job: ExportJob = 'ics';
|
||||
if (running[laneOf(job)]) return;
|
||||
begin(job);
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error();
|
||||
saveBlob(await res.blob(), `${fileBase}.ics`);
|
||||
onExported();
|
||||
} catch {
|
||||
toast.error(t('planner.icsExportFailed'));
|
||||
} finally {
|
||||
end(job);
|
||||
}
|
||||
};
|
||||
|
||||
/** One GPX scope; `query` narrows what the file carries, empty for everything. */
|
||||
const downloadGpx = async (key: string, query: string) => {
|
||||
const job: ExportJob = `gpx:${key}`;
|
||||
if (running[laneOf(job)]) return;
|
||||
begin(job);
|
||||
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`);
|
||||
onExported();
|
||||
} catch {
|
||||
toast.error(t('dayplan.gpxFailed'));
|
||||
} finally {
|
||||
end(job);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
fileBase,
|
||||
offerMine,
|
||||
isRunning,
|
||||
anyRunning: Object.keys(running).length > 0,
|
||||
exportPdf,
|
||||
downloadIcs,
|
||||
downloadGpx,
|
||||
};
|
||||
}
|
||||
@@ -3,15 +3,11 @@ import { CalendarPlus, ChevronRight, FileDown, Share2, UserRound } from 'lucide-
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeModal'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
import { hasPersonalPlan } from '../../../../components/PDF/pdfScope'
|
||||
import { useTripExport } from '../../../../components/Planner/useTripExport'
|
||||
import { INNER_CLS, TileHeader } from './MTripSheetUi'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { importChunk } from '../../../../utils/chunkReload'
|
||||
|
||||
/**
|
||||
* Export sheet ('export', opened from the Mehr sheet): the desktop day-plan
|
||||
@@ -21,106 +17,40 @@ import { importChunk } from '../../../../utils/chunkReload'
|
||||
*/
|
||||
export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
// The PDF is built outside React, so it cannot read this itself (#2066).
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
// Fed the way the desktop dialog feeds it: the store's assignments and the switch,
|
||||
// and the export applies the day plan's filter itself, so both shells print the same.
|
||||
const showServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false, planner.tripId)
|
||||
const open = shell.sheet?.id === 'export'
|
||||
const dayNotes = useTripStore(s => s.dayNotes)
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
const [pdfBusy, setPdfBusy] = useState<false | 'all' | 'mine'>(false)
|
||||
const myId = useAuthStore(s => s.user?.id)
|
||||
const offerMine = myId != null && hasPersonalPlan(planner.storedAssignments, planner.reservations)
|
||||
const [icsBusy, setIcsBusy] = useState(false)
|
||||
const [gpxBusy, setGpxBusy] = useState(false)
|
||||
// Fed the way the desktop dialog feeds it: the store's assignments, and the export
|
||||
// applies the day plan's filter itself, so both shells print the same. The PDF stays
|
||||
// open here; a calendar or GPX file closes the sheet.
|
||||
const { offerMine, isRunning, exportPdf, downloadIcs, downloadGpx } = useTripExport({
|
||||
tripId: planner.tripId,
|
||||
data: {
|
||||
trip: planner.trip,
|
||||
days: planner.days,
|
||||
places: planner.places,
|
||||
assignments: planner.storedAssignments,
|
||||
categories: planner.categories,
|
||||
reservations: planner.reservations,
|
||||
dayNotes,
|
||||
},
|
||||
t,
|
||||
locale,
|
||||
toast: planner.toast,
|
||||
exclusive: false,
|
||||
onExported: shell.closeSheet,
|
||||
requireTrip: true,
|
||||
closeAfterPdf: false,
|
||||
logPdfErrors: false,
|
||||
})
|
||||
// The subscription link reads the trip without an account, so it needs the
|
||||
// same permission as the public share link. The ICS download beside it does
|
||||
// not: that is a file this member may already read.
|
||||
const canManageShare = planner.can('share_manage', planner.trip)
|
||||
|
||||
const exportPdf = async (mine = false) => {
|
||||
if (!planner.trip || pdfBusy) return
|
||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
||||
notes.map(n => ({ ...n, day_id: Number(dayId) })),
|
||||
)
|
||||
setPdfBusy(mine ? 'mine' : 'all')
|
||||
try {
|
||||
// See DayPlanSidebarToolbar: loaded on demand, not with the trip.
|
||||
const { downloadTripPDF } = await importChunk(() => import('../../../../components/PDF/TripPDF'))
|
||||
await downloadTripPDF({
|
||||
trip: planner.trip,
|
||||
days: planner.days,
|
||||
places: planner.places,
|
||||
assignments: planner.storedAssignments,
|
||||
categories: planner.categories,
|
||||
dayNotes: flatNotes,
|
||||
reservations: planner.reservations,
|
||||
t,
|
||||
locale,
|
||||
timeFormat,
|
||||
distanceUnit,
|
||||
showServiceStops,
|
||||
onlyUserId: mine ? myId : undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
planner.toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`)
|
||||
} finally {
|
||||
setPdfBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadIcs = async () => {
|
||||
if (icsBusy) return
|
||||
setIcsBusy(true)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${planner.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 = `${planner.trip?.title || 'trip'}.ics`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
// Firefox/Safari cancel the download when the object URL is revoked
|
||||
// before they picked the blob up.
|
||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100)
|
||||
shell.closeSheet()
|
||||
} catch {
|
||||
planner.toast.error(t('planner.icsExportFailed'))
|
||||
} finally {
|
||||
setIcsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Everything in one file here: the desktop menu's three scopes are a hover
|
||||
// affordance the phone does not have, and "the whole trip" is what you want
|
||||
// on a device anyway.
|
||||
const downloadGpx = async () => {
|
||||
if (gpxBusy) return
|
||||
setGpxBusy(true)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${planner.tripId}/places/export.gpx`, { credentials: 'include' })
|
||||
if (res.status === 404) { planner.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 = `${planner.trip?.title || 'trip'}.gpx`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100)
|
||||
shell.closeSheet()
|
||||
} catch {
|
||||
planner.toast.error(t('dayplan.gpxFailed'))
|
||||
} finally {
|
||||
setGpxBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={shell.closeSheet} variant="card" material="glass" ariaLabel={t('mobileTrip.export')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
@@ -136,29 +66,29 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
<div className="flex flex-col gap-2">
|
||||
<ExportRow
|
||||
icon={FileDown}
|
||||
title={pdfBusy === 'all' ? t('common.loading') : t('dayplan.pdf')}
|
||||
title={isRunning('pdf') ? t('common.loading') : t('dayplan.pdf')}
|
||||
sub={t('dayplan.pdfTooltip')}
|
||||
onClick={() => void exportPdf()}
|
||||
/>
|
||||
{offerMine && (
|
||||
<ExportRow
|
||||
icon={UserRound}
|
||||
title={pdfBusy === 'mine' ? t('common.loading') : t('dayplan.pdfMine')}
|
||||
title={isRunning('pdf:mine') ? t('common.loading') : t('dayplan.pdfMine')}
|
||||
sub={t('dayplan.pdfMineSub')}
|
||||
onClick={() => void exportPdf(true)}
|
||||
/>
|
||||
)}
|
||||
<ExportRow
|
||||
icon={FileDown}
|
||||
title={icsBusy ? t('common.loading') : t('mobileTrip.icsDownload')}
|
||||
title={isRunning('ics') ? t('common.loading') : t('mobileTrip.icsDownload')}
|
||||
sub={`${planner.trip?.title || 'trip'}.ics`}
|
||||
onClick={() => void downloadIcs()}
|
||||
/>
|
||||
<ExportRow
|
||||
icon={Share2}
|
||||
title={gpxBusy ? t('common.loading') : t('dayplan.gpxAll')}
|
||||
title={isRunning('gpx:all') ? t('common.loading') : t('dayplan.gpxAll')}
|
||||
sub={t('dayplan.gpxTooltip')}
|
||||
onClick={() => void downloadGpx()}
|
||||
onClick={() => void downloadGpx('all', '')}
|
||||
/>
|
||||
{canManageShare && (
|
||||
<ExportRow
|
||||
|
||||
Reference in New Issue
Block a user