mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
refactor(planner): share the trip sheet wiring between desktop and phone
The phone trip sheet host wrote out by hand three pieces of planner wiring the desktop planner already had. useBookingExpenseEditor now holds the expense editor both planners open over the trip: the booking request state, the receipt fallback, the base currency, the current user and the save that reloads the budget. TripPlannerPage and MTripSheets both read it. applyTripCoverUpdate patches a new trip cover into the store for the trip form on both sides. The transport editor and the transit route change moved into openTransportEditorWith and changeTransitRouteWith, which take the planner's setters. usePlannerDialogs builds openTransportEditor and changeTransitRoute on them, and the phone sheet calls the same helpers on the planner it receives. There are no differences between the two views to keep as options.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import TransitJourneyModal from '../../../../components/Planner/TransitJourneyModal'
|
||||
import BookingImportModal from '../../../../components/Planner/BookingImportModal'
|
||||
@@ -6,11 +6,9 @@ import AirTrailImportModal from '../../../../components/Planner/AirTrailImportMo
|
||||
import TripFormModal from '../../../../components/Trips/TripFormModal'
|
||||
import TripMembersModal from '../../../../components/Trips/TripMembersModal'
|
||||
import TourDetailDialog from '../../../../components/Tours/TourDetailDialog'
|
||||
import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel'
|
||||
import { expenseEditorFor } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { useBookingExpenseEditor } from '../../../../pages/tripPlanner/useBookingExpenseEditor'
|
||||
import { applyTripCoverUpdate } from '../../../../pages/tripPlanner/tripCover'
|
||||
import { latestReservation } from '../../../../pages/tripPlanner/transportEditorOpeners'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import MDayImpactList from '../../../components/MDayImpactList'
|
||||
import MDaySheet from './MDaySheet'
|
||||
@@ -34,8 +32,6 @@ import MRtKindSheet from '../roadtrip/MRtKindSheet'
|
||||
import MRtInfoSheet from '../roadtrip/MRtInfoSheet'
|
||||
import MRtCorridorSheet from '../roadtrip/MRtCorridorSheet'
|
||||
import MRtDraftSheet from '../roadtrip/MRtDraftSheet'
|
||||
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
|
||||
import type { BudgetItem } from '../../../../types'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
import { lockBodyScroll } from '../../../../utils/bodyScrollLock'
|
||||
import { focusDialog, trapTab } from '../../../../components/shared/dialogFocus'
|
||||
@@ -110,16 +106,9 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
|
||||
// Booking-linked expense editor (save-then-open from the booking modals) —
|
||||
// same page-level wiring as the desktop planner.
|
||||
const meId = useAuthStore(s => s.user?.id ?? -1)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const loadBudgetItems = useTripStore(s => s.loadBudgetItems)
|
||||
const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(null)
|
||||
const openBookingExpense = (req: BookingExpenseRequest) => {
|
||||
if (req.editItem) setBookingExpense({ editing: req.editItem })
|
||||
else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill })
|
||||
}
|
||||
const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
const expenseEditor = expenseEditorFor(bookingExpense, () => setBookingExpense(null), planner.receiptExpense, planner.clearReceiptExpense)
|
||||
const { meId, costsBase, openBookingExpense, expenseEditor, onExpenseSaved } = useBookingExpenseEditor({
|
||||
tripId, tripCurrency: trip?.currency, receiptExpense: planner.receiptExpense, clearReceiptExpense: planner.clearReceiptExpense,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -180,34 +169,12 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
await planner.handleDeleteReservation(planner.transitJourney!.id)
|
||||
planner.setTransitJourney(null)
|
||||
}}
|
||||
onChangeRoute={() => {
|
||||
// Re-enter the transit search seeded with this journey's route; the
|
||||
// existing reservation is replaced on save.
|
||||
const journey = planner.transitJourney!
|
||||
const eps = journey.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
planner.setTransitPrefill({
|
||||
from: from ? { name: from.name, lat: from.lat, lng: from.lng } : null,
|
||||
to: to ? { name: to.name, lat: to.lat, lng: to.lng } : null,
|
||||
})
|
||||
planner.setEditingTransport(journey)
|
||||
planner.setTransportModalDayId(journey.day_id ?? null)
|
||||
planner.setTransportModalAutomated(true)
|
||||
planner.setTransitJourney(null)
|
||||
planner.setShowTransportModal(true)
|
||||
}}
|
||||
onEditDetails={() => {
|
||||
// Hand off to the full transport editor for the booking fields —
|
||||
// same target as the transports tab's pencil (#2148).
|
||||
const journey = planner.reservations.find(r => r.id === planner.transitJourney!.id) ?? planner.transitJourney!
|
||||
planner.setEditingTransport(journey)
|
||||
planner.setTransportModalDayId(journey.day_id ?? null)
|
||||
planner.setTransportModalAutomated(false)
|
||||
planner.setTransitPrefill(null)
|
||||
planner.setTransitJourney(null)
|
||||
planner.setShowTransportModal(true)
|
||||
}}
|
||||
// Re-enter the transit search seeded with this journey's route; the
|
||||
// existing reservation is replaced on save.
|
||||
onChangeRoute={() => planner.changeTransitRoute(planner.transitJourney!)}
|
||||
// Hand off to the full transport editor for the booking fields, the same
|
||||
// target as the transports tab's pencil (#2148). The store copy may be newer.
|
||||
onEditDetails={() => planner.openTransportEditor(latestReservation(planner.reservations, planner.transitJourney!))}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -221,7 +188,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
editing={expenseEditor.editing}
|
||||
prefill={expenseEditor.prefill}
|
||||
onClose={expenseEditor.close}
|
||||
onSaved={() => { expenseEditor.close(); loadBudgetItems(tripId) }}
|
||||
onSaved={onExpenseSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -237,9 +204,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
toast.success(t('trip.toast.tripUpdated'))
|
||||
}}
|
||||
trip={trip}
|
||||
onCoverUpdate={(_, coverUrl) => useTripStore.setState(state => ({
|
||||
trip: state.trip ? { ...state.trip, cover_image: coverUrl } : state.trip,
|
||||
}))}
|
||||
onCoverUpdate={applyTripCoverUpdate}
|
||||
/>
|
||||
<TripMembersModal
|
||||
isOpen={sheet?.id === 'members'}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, Suspense } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
||||
import type { CompassMap } from '../components/Map/MapCompassPill'
|
||||
@@ -25,10 +24,6 @@ import { BookingDetailPopup } from '../components/Planner/bookings/BookingDetail
|
||||
import BookingImportModal from '../components/Planner/BookingImportModal'
|
||||
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
|
||||
// MemoriesPanel moved to Journey addon
|
||||
import type { ExpensePrefill } from '../components/Budget/CostsPanel'
|
||||
import { expenseEditorFor } from '../components/Budget/CostsPanel.helpers'
|
||||
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
|
||||
import type { BudgetItem } from '../types'
|
||||
import PluginFrame from '../components/Plugins/PluginFrame'
|
||||
import ErrorBoundary from '../components/shared/ErrorBoundary'
|
||||
import { getDayBookendHotels } from '../utils/dayOrder'
|
||||
@@ -41,7 +36,6 @@ import { useToast } from '../components/shared/Toast'
|
||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, mapsApi } from '../api/client'
|
||||
import { accommodationRepo } from '../repo/accommodationRepo'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { Tooltip } from '../components/shared/Tooltip'
|
||||
import { useTripWebSocket } from '../hooks/useTripWebSocket'
|
||||
@@ -50,6 +44,9 @@ import { usePlaceSelection } from '../hooks/usePlaceSelection'
|
||||
import { usePlannerHistory } from '../hooks/usePlannerHistory'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../types'
|
||||
import { useTripPlannerPage } from './tripPlanner/useTripPlannerPage'
|
||||
import { useBookingExpenseEditor } from './tripPlanner/useBookingExpenseEditor'
|
||||
import { applyTripCoverUpdate } from './tripPlanner/tripCover'
|
||||
import { latestReservation } from './tripPlanner/transportEditorOpeners'
|
||||
import {
|
||||
ReservationsPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel,
|
||||
RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal,
|
||||
@@ -185,22 +182,13 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
|
||||
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
|
||||
// page level so it has tripMembers / base currency / current user available.
|
||||
const meId = useAuthStore(s => s.user?.id ?? -1)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const { meId, costsBase, openBookingExpense, expenseEditor, onExpenseSaved } = useBookingExpenseEditor({
|
||||
tripId, tripCurrency: trip?.currency, receiptExpense, clearReceiptExpense,
|
||||
})
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
// Transit search departs against a real date, so the whole Automated mode —
|
||||
// the day-header tram button and the modal's mode switch — is off without one.
|
||||
const tripHasDates = Boolean(trip?.start_date && trip?.end_date)
|
||||
const loadBudgetItems = useTripStore(s => s.loadBudgetItems)
|
||||
const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(null)
|
||||
const openBookingExpense = (req: BookingExpenseRequest) => {
|
||||
if (req.editItem) setBookingExpense({ editing: req.editItem })
|
||||
else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill })
|
||||
}
|
||||
// One expense editor for both openers: a booking's Costs block, and a scanned
|
||||
// receipt sent here from the background tasks widget.
|
||||
const expenseEditor = expenseEditorFor(bookingExpense, () => setBookingExpense(null), receiptExpense, clearReceiptExpense)
|
||||
|
||||
if (isLoading || !splashDone) {
|
||||
return (
|
||||
@@ -946,7 +934,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onClose={() => setShowTripForm(false)}
|
||||
onSave={async (data) => { await tripActions.updateTrip(tripId, data); loadAccommodations(); toast.success(t('trip.toast.tripUpdated')) }}
|
||||
trip={trip}
|
||||
onCoverUpdate={(_, coverUrl) => useTripStore.setState(state => ({ trip: state.trip ? { ...state.trip, cover_image: coverUrl } : state.trip }))}
|
||||
onCoverUpdate={applyTripCoverUpdate}
|
||||
/>
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} onMembersChanged={refreshMembers} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} tripMembers={tripMembers} />
|
||||
@@ -967,7 +955,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onDelete={async () => { await handleDeleteReservation(transitJourney.id); setTransitJourney(null) }}
|
||||
onChangeRoute={() => changeTransitRoute(transitJourney)}
|
||||
// The store copy may be newer than the journey held in state.
|
||||
onEditDetails={() => openTransportEditor(reservations.find(r => r.id === transitJourney.id) ?? transitJourney)}
|
||||
onEditDetails={() => openTransportEditor(latestReservation(reservations, transitJourney))}
|
||||
/>
|
||||
)}
|
||||
{/* A booking clicked on the desktop plan: its detail first, the editor one Edit away. */}
|
||||
@@ -1001,7 +989,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
editing={expenseEditor.editing}
|
||||
prefill={expenseEditor.prefill}
|
||||
onClose={expenseEditor.close}
|
||||
onSaved={() => { expenseEditor.close(); loadBudgetItems(tripId) }}
|
||||
onSaved={onExpenseSaved}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// FE-TP-TROPEN-001 to FE-TP-TROPEN-006
|
||||
//
|
||||
// The two ways from a saved transit journey into the transport editor. The planner's
|
||||
// dialogs run them on their own state, the phone trip sheets on the planner's setters.
|
||||
import { buildReservation } from '../../../tests/helpers/factories';
|
||||
import type { Reservation } from '../../types';
|
||||
import {
|
||||
changeTransitRouteWith,
|
||||
latestReservation,
|
||||
openTransportEditorWith,
|
||||
type TransportEditorSetters,
|
||||
} from './transportEditorOpeners';
|
||||
|
||||
function buildSetters() {
|
||||
const calls: string[] = [];
|
||||
const spy = (name: string) =>
|
||||
vi.fn(() => {
|
||||
calls.push(name);
|
||||
});
|
||||
const setters = {
|
||||
setEditingTransport: spy('editing'),
|
||||
setTransportModalDayId: spy('day'),
|
||||
setTransportModalAutomated: spy('automated'),
|
||||
setTransitPrefill: spy('prefill'),
|
||||
setTransitJourney: spy('journey'),
|
||||
setShowTransportModal: spy('show'),
|
||||
} satisfies TransportEditorSetters;
|
||||
return { setters, calls };
|
||||
}
|
||||
|
||||
const journey = {
|
||||
...buildReservation({ id: 55, day_id: 4, type: 'transit' }),
|
||||
endpoints: [
|
||||
{ role: 'from', name: 'Tokyo Sta.', lat: 35.68, lng: 139.76 },
|
||||
{ role: 'to', name: 'Kyoto Sta.', lat: 34.98, lng: 135.75 },
|
||||
],
|
||||
} as unknown as Reservation;
|
||||
|
||||
describe('openTransportEditorWith', () => {
|
||||
it('FE-TP-TROPEN-001: opens the manual editor on the entry and drops the journey view', () => {
|
||||
const { setters, calls } = buildSetters();
|
||||
openTransportEditorWith(setters, journey);
|
||||
expect(setters.setEditingTransport).toHaveBeenCalledWith(journey);
|
||||
expect(setters.setTransportModalDayId).toHaveBeenCalledWith(4);
|
||||
expect(setters.setTransportModalAutomated).toHaveBeenCalledWith(false);
|
||||
expect(setters.setTransitPrefill).toHaveBeenCalledWith(null);
|
||||
expect(setters.setTransitJourney).toHaveBeenCalledWith(null);
|
||||
expect(setters.setShowTransportModal).toHaveBeenCalledWith(true);
|
||||
expect(calls).toEqual(['editing', 'day', 'automated', 'prefill', 'journey', 'show']);
|
||||
});
|
||||
|
||||
it('FE-TP-TROPEN-002: an entry without a day opens the editor without one', () => {
|
||||
const { setters } = buildSetters();
|
||||
openTransportEditorWith(setters, { id: 9, trip_id: 1, type: 'train', title: 'Leg' } as unknown as Reservation);
|
||||
expect(setters.setTransportModalDayId).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeTransitRouteWith', () => {
|
||||
it('FE-TP-TROPEN-003: reopens the transit search seeded with both endpoints', () => {
|
||||
const { setters, calls } = buildSetters();
|
||||
changeTransitRouteWith(setters, journey);
|
||||
expect(setters.setTransitPrefill).toHaveBeenCalledWith({
|
||||
from: { name: 'Tokyo Sta.', lat: 35.68, lng: 139.76 },
|
||||
to: { name: 'Kyoto Sta.', lat: 34.98, lng: 135.75 },
|
||||
});
|
||||
expect(setters.setEditingTransport).toHaveBeenCalledWith(journey);
|
||||
expect(setters.setTransportModalDayId).toHaveBeenCalledWith(4);
|
||||
expect(setters.setTransportModalAutomated).toHaveBeenCalledWith(true);
|
||||
expect(setters.setTransitJourney).toHaveBeenCalledWith(null);
|
||||
expect(setters.setShowTransportModal).toHaveBeenCalledWith(true);
|
||||
expect(calls).toEqual(['prefill', 'editing', 'day', 'automated', 'journey', 'show']);
|
||||
});
|
||||
|
||||
it('FE-TP-TROPEN-004: a journey without endpoints seeds empty prefills and no day', () => {
|
||||
const { setters } = buildSetters();
|
||||
const bare = { id: 56, trip_id: 1, type: 'transit', title: 'Unknown leg' } as unknown as Reservation;
|
||||
changeTransitRouteWith(setters, bare);
|
||||
expect(setters.setTransitPrefill).toHaveBeenCalledWith({ from: null, to: null });
|
||||
expect(setters.setTransportModalDayId).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('latestReservation', () => {
|
||||
it('FE-TP-TROPEN-005: prefers the store copy of the journey when it is newer', () => {
|
||||
const fresh = { ...journey, title: 'Tokyo to Kyoto (saved)' } as Reservation;
|
||||
const other = buildReservation({ id: 57, type: 'train' });
|
||||
expect(latestReservation([other, fresh], journey)).toBe(fresh);
|
||||
});
|
||||
|
||||
it('FE-TP-TROPEN-006: falls back to the held journey when the store has no copy', () => {
|
||||
const other = buildReservation({ id: 57, type: 'train' });
|
||||
expect(latestReservation([other], journey)).toBe(journey);
|
||||
expect(latestReservation([], journey)).toBe(journey);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Reservation } from '../../types';
|
||||
|
||||
type TransitPoint = { name: string; lat: number; lng: number } | null;
|
||||
|
||||
/** The planner state the transport editor opens from. usePlannerDialogs owns it. */
|
||||
export interface TransportEditorSetters {
|
||||
setEditingTransport: (r: Reservation | null) => void;
|
||||
setTransportModalDayId: (dayId: number | null) => void;
|
||||
setTransportModalAutomated: (automated: boolean) => void;
|
||||
setTransitPrefill: (prefill: { from?: TransitPoint; to?: TransitPoint; time?: string | null } | null) => void;
|
||||
setTransitJourney: (r: Reservation | null) => void;
|
||||
setShowTransportModal: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The store copy of a journey held in planner state. A save or a socket update may
|
||||
* have replaced the entry since the journey view opened; without a match the held
|
||||
* copy is the best there is.
|
||||
*/
|
||||
export function latestReservation(reservations: Reservation[], r: Reservation): Reservation {
|
||||
return reservations.find((x) => x.id === r.id) ?? r;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full transport editor on a saved entry. For a transit journey that is where
|
||||
* travellers, costs, files, code and status live; an unchanged-endpoints save keeps
|
||||
* the stored itinerary (#2148).
|
||||
*/
|
||||
export function openTransportEditorWith(setters: TransportEditorSetters, r: Reservation): void {
|
||||
setters.setEditingTransport(r);
|
||||
setters.setTransportModalDayId(r.day_id ?? null);
|
||||
setters.setTransportModalAutomated(false);
|
||||
setters.setTransitPrefill(null);
|
||||
setters.setTransitJourney(null);
|
||||
setters.setShowTransportModal(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enters the transit search seeded with a journey's route; the journey is
|
||||
* REPLACED on save (editingTransport drives handleSaveTransport's update path).
|
||||
*/
|
||||
export function changeTransitRouteWith(setters: TransportEditorSetters, r: Reservation): void {
|
||||
const eps = r.endpoints || [];
|
||||
const from = eps.find((e) => e.role === 'from');
|
||||
const to = eps.find((e) => e.role === 'to');
|
||||
setters.setTransitPrefill({
|
||||
from: from ? { name: from.name, lat: from.lat, lng: from.lng } : null,
|
||||
to: to ? { name: to.name, lat: to.lat, lng: to.lng } : null,
|
||||
});
|
||||
setters.setEditingTransport(r);
|
||||
setters.setTransportModalDayId(r.day_id ?? null);
|
||||
setters.setTransportModalAutomated(true);
|
||||
setters.setTransitJourney(null);
|
||||
setters.setShowTransportModal(true);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// FE-TP-COVER-001 to FE-TP-COVER-002
|
||||
//
|
||||
// The cover update the trip form hands back, on the desktop planner and the phone trip
|
||||
// sheets alike.
|
||||
import { buildTrip } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { applyTripCoverUpdate } from './tripCover';
|
||||
|
||||
describe('applyTripCoverUpdate', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
it('FE-TP-COVER-001: patches the new cover into the loaded trip and keeps the rest', () => {
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1, title: 'Japan 2026', cover_image: '/uploads/covers/old.jpg' }),
|
||||
});
|
||||
applyTripCoverUpdate(1, '/uploads/covers/new.jpg');
|
||||
expect(useTripStore.getState().trip).toMatchObject({ title: 'Japan 2026', cover_image: '/uploads/covers/new.jpg' });
|
||||
|
||||
applyTripCoverUpdate(1, null);
|
||||
expect(useTripStore.getState().trip?.cover_image).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TP-COVER-002: leaves the store alone without a loaded trip', () => {
|
||||
seedStore(useTripStore, { trip: null });
|
||||
applyTripCoverUpdate(1, '/uploads/covers/new.jpg');
|
||||
expect(useTripStore.getState().trip).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
|
||||
/**
|
||||
* TripFormModal's onCoverUpdate for the open trip: the new cover goes straight into
|
||||
* the store's trip, so the planner shows it without reloading the trip.
|
||||
*/
|
||||
export function applyTripCoverUpdate(_tripId: number, coverUrl: string | null): void {
|
||||
useTripStore.setState((state) => ({
|
||||
trip: state.trip ? { ...state.trip, cover_image: coverUrl } : state.trip,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// FE-TP-BOOKEXP-001 to FE-TP-BOOKEXP-009
|
||||
//
|
||||
// The expense editor the trip planner opens over itself, driven straight through
|
||||
// useBookingExpenseEditor. The desktop planner and the phone trip sheets render the
|
||||
// same editor from it, so every case holds for both.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { buildBudgetItem } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import type { ExpensePrefill } from '../../components/Budget/CostsPanel';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useBookingExpenseEditor } from './useBookingExpenseEditor';
|
||||
|
||||
interface Props {
|
||||
tripCurrency?: string | null;
|
||||
receiptExpense?: ExpensePrefill | null;
|
||||
}
|
||||
|
||||
const clearReceiptExpense = vi.fn();
|
||||
|
||||
function renderEditor(initial: Props = {}) {
|
||||
return renderHook(
|
||||
(props: Props) =>
|
||||
useBookingExpenseEditor({
|
||||
tripId: 42,
|
||||
tripCurrency: props.tripCurrency,
|
||||
receiptExpense: props.receiptExpense ?? null,
|
||||
clearReceiptExpense,
|
||||
}),
|
||||
{ initialProps: initial }
|
||||
);
|
||||
}
|
||||
|
||||
describe('useBookingExpenseEditor', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
clearReceiptExpense.mockClear();
|
||||
seedStore(useAuthStore, { user: { id: 7, username: 'maurice', email: 'm@example.com' } });
|
||||
});
|
||||
|
||||
it('FE-TP-BOOKEXP-001: no editor until a booking or a receipt asks for one', () => {
|
||||
const { result } = renderEditor();
|
||||
expect(result.current.expenseEditor).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TP-BOOKEXP-002: a booking opens its linked expense for editing and closes it again', () => {
|
||||
const item = buildBudgetItem({ id: 99 });
|
||||
const { result } = renderEditor();
|
||||
act(() => {
|
||||
result.current.openBookingExpense({ editItem: item });
|
||||
});
|
||||
expect(result.current.expenseEditor).toMatchObject({ key: 'booking', editing: item, prefill: undefined });
|
||||
act(() => {
|
||||
result.current.expenseEditor!.close();
|
||||
});
|
||||
expect(result.current.expenseEditor).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TP-BOOKEXP-003: a booking without a linked expense opens a new one prefilled', () => {
|
||||
const { result } = renderEditor();
|
||||
act(() => {
|
||||
result.current.openBookingExpense({ prefill: { reservationId: 5, name: 'Train', amount: 30 } });
|
||||
});
|
||||
expect(result.current.expenseEditor).toMatchObject({
|
||||
key: 'booking',
|
||||
editing: null,
|
||||
prefill: { reservationId: 5, name: 'Train', amount: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-TP-BOOKEXP-004: a request with neither an item nor a prefill opens nothing', () => {
|
||||
const { result } = renderEditor();
|
||||
act(() => {
|
||||
result.current.openBookingExpense({});
|
||||
});
|
||||
expect(result.current.expenseEditor).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TP-BOOKEXP-005: a scanned receipt opens a new expense and its close clears the receipt', () => {
|
||||
const { result } = renderEditor({ receiptExpense: { name: 'Lunch', amount: 12 } });
|
||||
expect(result.current.expenseEditor).toMatchObject({
|
||||
key: 'receipt',
|
||||
editing: null,
|
||||
prefill: { name: 'Lunch', amount: 12 },
|
||||
});
|
||||
act(() => {
|
||||
result.current.expenseEditor!.close();
|
||||
});
|
||||
expect(clearReceiptExpense).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-TP-BOOKEXP-006: a booking request wins over a waiting receipt', () => {
|
||||
const item = buildBudgetItem({ id: 3 });
|
||||
const { result } = renderEditor({ receiptExpense: { name: 'Lunch' } });
|
||||
act(() => {
|
||||
result.current.openBookingExpense({ editItem: item });
|
||||
});
|
||||
expect(result.current.expenseEditor?.key).toBe('booking');
|
||||
});
|
||||
|
||||
it('FE-TP-BOOKEXP-007: the base currency prefers the display setting, then the trip, then EUR', () => {
|
||||
seedStore(useSettingsStore, { settings: { default_currency: 'usd' } });
|
||||
const { result, rerender } = renderEditor({ tripCurrency: 'jpy' });
|
||||
expect(result.current.costsBase).toBe('USD');
|
||||
|
||||
act(() => {
|
||||
seedStore(useSettingsStore, { settings: { default_currency: '' } });
|
||||
});
|
||||
rerender({ tripCurrency: 'jpy' });
|
||||
expect(result.current.costsBase).toBe('JPY');
|
||||
|
||||
rerender({ tripCurrency: null });
|
||||
expect(result.current.costsBase).toBe('EUR');
|
||||
});
|
||||
|
||||
it('FE-TP-BOOKEXP-008: the current user pays, an anonymous session as user -1', () => {
|
||||
const { result } = renderEditor();
|
||||
expect(result.current.meId).toBe(7);
|
||||
act(() => {
|
||||
seedStore(useAuthStore, { user: null });
|
||||
});
|
||||
expect(result.current.meId).toBe(-1);
|
||||
});
|
||||
|
||||
it('FE-TP-BOOKEXP-009: a save closes the editor and reloads the trip budget', () => {
|
||||
const loadBudgetItems = vi.fn(async () => undefined);
|
||||
seedStore(useTripStore, { loadBudgetItems });
|
||||
const { result } = renderEditor();
|
||||
act(() => {
|
||||
result.current.openBookingExpense({ prefill: { reservationId: 5 } });
|
||||
});
|
||||
act(() => {
|
||||
result.current.onExpenseSaved();
|
||||
});
|
||||
expect(result.current.expenseEditor).toBeNull();
|
||||
expect(loadBudgetItems).toHaveBeenCalledWith(42);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { ExpensePrefill } from '../../components/Budget/CostsPanel';
|
||||
import { expenseEditorFor } from '../../components/Budget/CostsPanel.helpers';
|
||||
import type { BookingExpenseRequest } from '../../components/Planner/BookingCostsSection.types';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { BudgetItem } from '../../types';
|
||||
|
||||
interface BookingExpenseEditorOptions {
|
||||
tripId: number;
|
||||
/** The trip's own currency, the fallback when the user has no display currency set. */
|
||||
tripCurrency: string | null | undefined;
|
||||
/** A scanned receipt sent to the trip from the background tasks widget. */
|
||||
receiptExpense: ExpensePrefill | null;
|
||||
clearReceiptExpense: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The expense editor the trip planner opens over itself: from a booking's Costs block
|
||||
* (save then open) and for a scanned receipt. The desktop shows it as ExpenseModal, the
|
||||
* phone as MCostSheet; both read the same editor, base currency and current user here.
|
||||
*/
|
||||
export function useBookingExpenseEditor(options: BookingExpenseEditorOptions) {
|
||||
const { tripId, tripCurrency, receiptExpense, clearReceiptExpense } = options;
|
||||
const meId = useAuthStore((s) => s.user?.id ?? -1);
|
||||
const displayCurrency = useSettingsStore((s) => s.settings.default_currency);
|
||||
const loadBudgetItems = useTripStore((s) => s.loadBudgetItems);
|
||||
const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(
|
||||
null
|
||||
);
|
||||
const openBookingExpense = (req: BookingExpenseRequest) => {
|
||||
if (req.editItem) setBookingExpense({ editing: req.editItem });
|
||||
else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill });
|
||||
};
|
||||
const costsBase = (displayCurrency || tripCurrency || 'EUR').toUpperCase();
|
||||
// One expense editor for both openers: a booking's Costs block, and a scanned
|
||||
// receipt sent here from the background tasks widget.
|
||||
const expenseEditor = expenseEditorFor(
|
||||
bookingExpense,
|
||||
() => setBookingExpense(null),
|
||||
receiptExpense,
|
||||
clearReceiptExpense
|
||||
);
|
||||
const onExpenseSaved = () => {
|
||||
expenseEditor?.close();
|
||||
loadBudgetItems(tripId);
|
||||
};
|
||||
return { meId, costsBase, openBookingExpense, expenseEditor, onExpenseSaved };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPo
|
||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
import type { Place, Reservation } from '../../types'
|
||||
import type { PlannerBase, PlannerSearchParams } from './plannerTypes'
|
||||
import { changeTransitRouteWith, openTransportEditorWith } from './transportEditorOpeners'
|
||||
|
||||
interface PlannerDialogsOptions extends Pick<PlannerBase, 'tripId' | 'tripActions'> {
|
||||
searchParams: PlannerSearchParams[0]
|
||||
@@ -136,32 +137,14 @@ export function usePlannerDialogs(options: PlannerDialogsOptions) {
|
||||
// whose editor also asks for day_edit.
|
||||
const [bookingDetailOpen, setBookingDetailOpen] = useState<{ id: number; fromDayList: boolean } | null>(null)
|
||||
|
||||
// The full transport editor on a saved entry. For a transit journey that is where
|
||||
// travellers, costs, files, code and status live; an unchanged-endpoints save keeps
|
||||
// the stored itinerary (#2148).
|
||||
// The full transport editor on a saved entry, and the transit search re-entered
|
||||
// seeded with a journey's route. The phone trip sheets open them with the same
|
||||
// helpers on the planner's setters.
|
||||
const openTransportEditor = useCallback((r: Reservation) => {
|
||||
setEditingTransport(r)
|
||||
setTransportModalDayId(r.day_id ?? null)
|
||||
setTransportModalAutomated(false)
|
||||
setTransitPrefill(null)
|
||||
setTransitJourney(null)
|
||||
setShowTransportModal(true)
|
||||
openTransportEditorWith({ setEditingTransport, setTransportModalDayId, setTransportModalAutomated, setTransitPrefill, setTransitJourney, setShowTransportModal }, r)
|
||||
}, [])
|
||||
// Re-enters the transit search seeded with a journey's route; the journey is
|
||||
// REPLACED on save (editingTransport drives handleSaveTransport's update path).
|
||||
const changeTransitRoute = useCallback((r: Reservation) => {
|
||||
const eps = r.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
setTransitPrefill({
|
||||
from: from ? { name: from.name, lat: from.lat, lng: from.lng } : null,
|
||||
to: to ? { name: to.name, lat: to.lat, lng: to.lng } : null,
|
||||
})
|
||||
setEditingTransport(r)
|
||||
setTransportModalDayId(r.day_id ?? null)
|
||||
setTransportModalAutomated(true)
|
||||
setTransitJourney(null)
|
||||
setShowTransportModal(true)
|
||||
changeTransitRouteWith({ setEditingTransport, setTransportModalDayId, setTransportModalAutomated, setTransitPrefill, setTransitJourney, setShowTransportModal }, r)
|
||||
}, [])
|
||||
|
||||
// The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs
|
||||
|
||||
Reference in New Issue
Block a user