fix(tours): restrict permanent deletion to tours workspace

This commit is contained in:
elmocito
2026-10-06 12:52:25 +02:00
parent 04c7491e2a
commit 8739b4c74d
17 changed files with 456 additions and 39 deletions
+9 -5
View File
@@ -337,6 +337,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
route, routeWalking, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, handlePoiClick,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
handleDeleteTour,
handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, dayAdd, handleUpdateDayTitle,
deleteDayQuestion, handleDeleteDay,
clearDayId, clearDayTitle, handleClearDay, cancelClearDay, confirmClearDay,
@@ -698,6 +699,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
trip={trip}
days={days}
places={places}
tourPlaceIds={tourPlaceIds}
categories={categories}
assignments={storedAssignments}
selectedDayId={selectedDayId}
@@ -1023,7 +1025,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onFileUpload={canUploadFiles ? (formData) => tripActions.addFile(tripId, formData) : undefined}
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
onDelete={() => handleDeletePlace(selectedPlace.id)}
leftWidth={leftPanelPx}
rightWidth={rightPanelPx}
/>
@@ -1047,7 +1048,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onFileUpload={canUploadFiles ? (formData) => tripActions.addFile(tripId, formData) : undefined}
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
/>
</div>
</div>,
@@ -1065,8 +1065,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} onClearSelectedDay={() => handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} tourPlaceIds={tourPlaceIds} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
: <PlacesSidebar tripId={tripId} places={places} toursEnabled={toursEnabled} excludePlaceIds={toursEnabled ? tourPlaceIds : undefined} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} onClearSelectedDay={() => handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
</div>
@@ -1160,6 +1160,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
days={days}
loading={toursLoading}
onAssignToDay={(placeId, dayId) => handleAssignToDay(placeId, dayId)}
onDeleteTour={handleDeleteTour}
onViewGpxTour={tour => {
const place = places.find(item => item.id === tour.place_id)
tourPlanner.viewGpxTour(tour, place?.route_geometry ?? null)
@@ -1380,7 +1381,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
<ConfirmDialog
isOpen={!!deletePlaceId}
onClose={() => setDeletePlaceId(null)}
onConfirm={confirmDeletePlace}
onConfirm={async () => {
const deletedTourId = await confirmDeletePlace()
if (deletedTourId != null) tourPlanner.forgetDeletedTour(deletedTourId)
}}
title={t('common.delete')}
message={deletePlaceIsTour
? [t('tours.delete.confirmBody'), deletePlaceNote].filter(Boolean).join(' ')
@@ -26,6 +26,7 @@ const plannerActions = vi.hoisted(() => ({
viewGpxTour: vi.fn(),
addWaypoint: vi.fn(),
openTour: vi.fn(),
forgetDeletedTour: vi.fn((_placeId: number) => undefined),
mode: { type: 'neutral' } as unknown,
isSaving: false,
waypoints: [] as unknown,
@@ -127,6 +128,7 @@ vi.mock('../components/Tours/planner/useTourPlanner', () => ({
readOnlyGpxTour: plannerActions.readOnlyGpxTour,
readOnlyGpxAnalysis: plannerActions.readOnlyGpxAnalysis,
discard: vi.fn(),
forgetDeletedTour: plannerActions.forgetDeletedTour,
}),
}))
@@ -383,6 +385,7 @@ function baseState(): HookState {
handleSavePlace: vi.fn(async () => undefined),
openPlaceEditor: vi.fn(),
handleDeletePlace: vi.fn(),
handleDeleteTour: vi.fn(),
confirmDeletePlace: vi.fn(async () => undefined),
confirmDeletePlaces: vi.fn(async () => undefined),
confirmChangeCategory: vi.fn(async () => undefined),
@@ -435,6 +438,7 @@ beforeEach(() => {
plannerActions.viewGpxTour.mockReset()
plannerActions.addWaypoint.mockReset()
plannerActions.openTour.mockReset()
plannerActions.forgetDeletedTour.mockReset()
plannerActions.mode = { type: 'neutral' }
plannerActions.isSaving = false
plannerActions.waypoints = []
@@ -621,6 +625,137 @@ describe('TripPlannerPage — shell', () => {
expect(screen.queryByTestId('tour-planner-rail')).not.toBeInTheDocument()
})
it('does not offer permanent Tour Delete from the desktop TRIP-PLAN detail', () => {
const tourPlace = buildPlace({ id: 91, name: 'Ridge walk', tour_place_id: 91 })
const selectedTour = {
place_id: 91,
name: tourPlace.name,
tour_type: 'hike',
distance: 8,
elevation_gain: 420,
elevation_loss: 390,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: true,
caution: false,
has_waypoints: true,
}
renderPage({
toursEnabled: true,
selectedPlace: tourPlace,
selectedPlaceId: tourPlace.id,
selectedTour,
})
expect(props('tourDetail').onDelete).toBeUndefined()
})
it('routes visible TOUR-PLANNER Tours-rail deletion through the existing confirmation', async () => {
const savedTour = {
place_id: 91,
name: 'Ridge walk',
tour_type: 'hike',
distance: 8,
elevation_gain: 420,
elevation_loss: 390,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: false,
caution: false,
has_waypoints: true,
}
const handleDeleteTour = vi.fn((placeId: number) => {
hookState = { ...hookState, deletePlaceId: placeId, deletePlaceIsTour: true }
})
const setDeletePlaceId = vi.fn((placeId: number | null) => {
hookState = { ...hookState, deletePlaceId: placeId }
})
const confirmDeletePlace = vi.fn(async (): Promise<number | null> => savedTour.place_id)
plannerActions.mode = { type: 'edit-saved', placeId: savedTour.place_id }
plannerActions.forgetDeletedTour.mockImplementation(placeId => {
const mode = plannerActions.mode as { placeId?: number }
if (mode.placeId === placeId) plannerActions.mode = { type: 'neutral' }
})
const view = renderPage({
activeTab: 'tour-planner',
enabledAddons: { packing: true, budget: true, documents: true, collab: true, tours: true },
toursEnabled: true,
tours: [savedTour],
handleDeleteTour,
setDeletePlaceId,
confirmDeletePlace,
})
fireEvent.click(await screen.findByRole('button', { name: 'Delete Ridge walk' }))
expect(handleDeleteTour).toHaveBeenCalledWith(savedTour.place_id)
view.rerender(<TripPlannerPage />)
let confirmation = [...confirmDialogs].reverse().find(dialog => dialog.isOpen)
expect(confirmation).toMatchObject({ title: 'common.delete', confirmLabel: 'tours.delete.confirmAction' })
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
const onClose = confirmation?.onClose as (() => void) | undefined
onClose?.()
expect(setDeletePlaceId).toHaveBeenCalledWith(null)
expect(confirmDeletePlace).not.toHaveBeenCalled()
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Delete Ridge walk' }))
view.rerender(<TripPlannerPage />)
confirmation = [...confirmDialogs].reverse().find(dialog => dialog.isOpen)
const onConfirm = confirmation?.onConfirm as (() => void | Promise<void>) | undefined
await act(async () => { await onConfirm?.() })
expect(confirmDeletePlace).toHaveBeenCalledOnce()
expect(plannerActions.forgetDeletedTour).toHaveBeenCalledWith(savedTour.place_id)
view.rerender(<TripPlannerPage />)
expect(screen.getByText('Plan a tour')).toBeInTheDocument()
confirmDeletePlace.mockResolvedValue(null)
plannerActions.forgetDeletedTour.mockClear()
plannerActions.mode = { type: 'edit-saved', placeId: savedTour.place_id }
fireEvent.click(screen.getByRole('button', { name: 'Delete Ridge walk' }))
view.rerender(<TripPlannerPage />)
confirmation = [...confirmDialogs].reverse().find(dialog => dialog.isOpen)
const failedConfirm = confirmation?.onConfirm as (() => void | Promise<void>) | undefined
await act(async () => { await failedConfirm?.() })
expect(plannerActions.forgetDeletedTour).not.toHaveBeenCalled()
expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument()
view.rerender(<TripPlannerPage />)
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
})
it('excludes Tours from the mobile TRIP-PLAN Places pool when the addon is enabled', () => {
const tourPlace = buildPlace({ id: 92, name: 'Mobile ridge', tour_place_id: 92 })
const tourPlaceIds = new Set([tourPlace.id])
renderPage({
isMobile: true,
mobileSidebarOpen: 'right',
toursEnabled: true,
tourPlaceIds,
places: [tourPlace],
})
expect(props('places').toursEnabled).toBe(true)
expect(props('places').excludePlaceIds).toBe(tourPlaceIds)
})
it('passes Tour facet identity to desktop and mobile TRIP-PLAN day cards', () => {
const tourPlaceIds = new Set([92])
const view = renderPage({ activeTab: 'plan', toursEnabled: true, tourPlaceIds })
expect(props('dayPlan').tourPlaceIds).toBe(tourPlaceIds)
hookState = { ...hookState, isMobile: true, mobileSidebarOpen: 'left' }
view.rerender(<TripPlannerPage />)
expect(props('dayPlan').tourPlaceIds).toBe(tourPlaceIds)
})
it('FE-PAGE-TPW-062: opens GPX geometry in the read-only Planner path without mounting the general detail dialog', async () => {
const gpxPlace = buildPlace({ id: 91, name: 'Imported ridge GPX', lat: 47.1, lng: 11.2, route_geometry: '[[47.1,11.2,500],[47.2,11.3,550]]' })
const gpxTour = {
@@ -1319,7 +1319,7 @@ describe('useTripPlanner — place CRUD', () => {
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 7, 900, 2)
})
it('permanently deletes a Tour without Place restore and forgets only its stale assignment undo', async () => {
it('preserves legacy deletion behavior for a dormant Tour and forgets only its stale assignment undo', async () => {
const tourPlace = buildPlace({ id: 1, name: 'Ridge walk', tour_place_id: 1 })
const unrelatedUndo = vi.fn()
const tourAssignmentUndo = vi.fn()
@@ -1345,6 +1345,48 @@ describe('useTripPlanner — place CRUD', () => {
expect(tourAssignmentUndo).not.toHaveBeenCalled()
})
it('allows permanent Tour deletion only in TOUR-PLANNER and leaves cancellation inert', async () => {
const tourPlace = buildPlace({ id: 1, name: 'Ridge walk', tour_place_id: 1 })
seedTrip({ places: [tourPlace] })
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true })
vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [] })
actions.deletePlace.mockResolvedValue({ success: true, tourPlaceIds: [1] })
const { result } = await renderPlanner()
await waitFor(() => expect(result.current.toursEnabled).toBe(true))
act(() => {
result.current.handleDeletePlace(1)
})
expect(result.current.deletePlaceId).toBeNull()
expect(actions.deletePlace).not.toHaveBeenCalled()
act(() => {
result.current.handleDeleteTour(1)
})
expect(result.current.deletePlaceId).toBe(1)
act(() => {
result.current.setDeletePlaceId(null)
})
await act(async () => {
await result.current.confirmDeletePlace()
})
expect(actions.deletePlace).not.toHaveBeenCalled()
act(() => {
result.current.handleDeleteTour(1)
})
let deletedTourId: number | null = null
await act(async () => {
deletedTourId = await result.current.confirmDeletePlace()
})
expect(actions.deletePlace).toHaveBeenCalledWith(42, 1)
expect(actions.deletePlace).toHaveBeenCalledTimes(1)
expect(deletedTourId).toBe(1)
expect(actions.addPlace).not.toHaveBeenCalled()
expect(result.current.canUndo).toBe(false)
})
it('uses the server facet result to suppress Undo for a dormant Tour when Tours is off', async () => {
const dormantPlace = buildPlace({ id: 1, name: 'Dormant route' })
seedTrip({ places: [dormantPlace] })
@@ -1385,14 +1427,39 @@ describe('useTripPlanner — place CRUD', () => {
})
it('FE-TP-HOOK-057: a failing delete surfaces the server message', async () => {
seedTrip({ places: [buildPlace({ id: 1, lat: 1, lng: 2 })] })
actions.deletePlace.mockRejectedValue(new Error('place is locked'))
const place = buildPlace({ id: 1, lat: 1, lng: 2, tour_place_id: 1 })
const listedTour: TourListItem = {
place_id: 1,
name: 'Ridge walk',
tour_type: 'hike',
distance: 4,
elevation_gain: 100,
elevation_loss: 80,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: false,
caution: false,
}
seedTrip({ places: [place] })
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true })
vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [listedTour] })
actions.deletePlace.mockRejectedValue(new Error('Tour is locked'))
const { result } = await renderPlanner()
act(() => { result.current.handleDeletePlace(1) })
await act(async () => { await result.current.confirmDeletePlace() })
await waitFor(() => expect(result.current.tours).toEqual([listedTour]))
act(() => { result.current.handleDeleteTour(1) })
let failedTourId: number | null = 1
await act(async () => { failedTourId = await result.current.confirmDeletePlace() })
expect(toasts.some(t => t.message === 'place is locked' && t.type === 'error')).toBe(true)
expect(failedTourId).toBeNull()
expect(result.current.tours).toEqual([listedTour])
expect(useTripStore.getState().places).toContain(place)
expect(toasts.some(t => t.message === 'Tour is locked' && t.type === 'error')).toBe(true)
expect(toasts.some(t => t.type === 'success')).toBe(false)
})
it('FE-TP-HOOK-058: a bulk delete restores every place with its assignments on undo', async () => {
@@ -1751,10 +1818,13 @@ describe('useTripPlanner — day plan CRUD', () => {
})
it('FE-TP-HOOK-066: removing a Tour assignment can be undone without deleting the Tour', async () => {
const place = buildPlace({ id: 1, lat: 1, lng: 2, tour_place_id: 1 })
const place = buildPlace({ id: 1, lat: 1, lng: 2, tour_place_id: 1, route_geometry: '[[1,2],[3,4]]' })
seedTrip({
places: [place],
assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place, order_index: 4 })] },
assignments: {
'7': [buildAssignment({ id: 10, day_id: 7, place, order_index: 4 })],
'8': [buildAssignment({ id: 11, day_id: 8, place, order_index: 2 })],
},
})
const { result } = await renderPlanner()
@@ -1763,6 +1833,9 @@ describe('useTripPlanner — day plan CRUD', () => {
expect(actions.removeAssignment).toHaveBeenCalledWith(42, 7, 10)
expect(actions.deletePlace).not.toHaveBeenCalled()
expect(actions.addPlace).not.toHaveBeenCalled()
expect(useTripStore.getState().places).toContainEqual(expect.objectContaining({ id: place.id, route_geometry: place.route_geometry }))
expect(useTripStore.getState().assignments['8']).toHaveLength(1)
expect(useTripStore.getState().assignments['8'][0].id).toBe(11)
await act(async () => { await result.current.undo() })
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 7, 1, 4)
+26 -8
View File
@@ -2415,13 +2415,25 @@ export function useTripPlanner() {
setStayDraft(draft)
}, [])
const handleDeletePlace = useCallback((placeId) => {
if (!can('place_edit', trip)) return
setDeletePlaceId(placeId)
}, [can, trip])
const handleDeletePlace = useCallback(
(placeId) => {
if (!can('place_edit', trip)) return
if (toursEnabled && isTourPlace(placeId)) return
setDeletePlaceId(placeId)
},
[can, trip, toursEnabled, isTourPlace]
)
const handleDeleteTour = useCallback(
(placeId: number) => {
if (!can('place_edit', trip) || !toursEnabled || !isTourPlace(placeId)) return
setDeletePlaceId(placeId)
},
[can, trip, toursEnabled, isTourPlace]
)
const confirmDeletePlace = useCallback(async () => {
if (!deletePlaceId) return
if (!deletePlaceId) return null
const state = useTripStore.getState()
const capturedPlace = state.places.find(p => p.id === deletePlaceId)
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
@@ -2432,7 +2444,8 @@ export function useTripPlanner() {
const deletedTour = Array.isArray(deletion?.tourPlaceIds)
? deletion.tourPlaceIds.includes(deletePlaceId)
: isTourPlace(deletePlaceId)
void reloadTourPlaceIds()
if (deletedTour) invalidateTourPlaceIds({ removedPlaceIds: [deletePlaceId] })
else void reloadTourPlaceIds()
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
updateRouteForDay(selectedDayId)
toast.success(t('trip.toast.placeDeleted'))
@@ -2457,8 +2470,12 @@ export function useTripPlanner() {
}
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo, forgetPlace, reloadTourPlaceIds, isTourPlace])
return deletedTour ? deletePlaceId : null
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
return null
}
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo, forgetPlace, invalidateTourPlaceIds, reloadTourPlaceIds, isTourPlace])
const confirmDeletePlaces = useCallback(async (ids?: number[]) => {
const targetIds = ids ?? deletePlaceIds
@@ -3072,6 +3089,7 @@ export function useTripPlanner() {
route, routeWalking, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, handlePoiClick,
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
handleDeleteTour,
handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, dayAdd, handleUpdateDayTitle,
...dayDelete,
...dayClear,