diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx index 0c3325aaa..94bf45018 100644 --- a/client/src/components/Planner/PlacesSidebar.test.tsx +++ b/client/src/components/Planner/PlacesSidebar.test.tsx @@ -336,6 +336,39 @@ describe('Category filter dropdown', () => { await user.click(parkOpts[parkOpts.length - 1]); expect(screen.getByText(/2 categories/i)).toBeInTheDocument(); }); + + it('FE-PLANNER-SIDEBAR-047: category filter survives unmount/remount (#1541)', async () => { + const user = userEvent.setup(); + const cat = buildCategory({ name: 'Hotel', color: '#3b82f6' }); + const withCat = buildPlace({ name: 'Grand Palace', category_id: cat.id, address: 'Vienna' }); + const noCat = buildPlace({ name: 'Street Market', category_id: null, address: 'Lisbon' }); + const { unmount } = render(); + await user.click(screen.getByText(/All Categories/i)); + await user.click(screen.getByText('Hotel')); + expect(screen.queryByText('Street Market')).not.toBeInTheDocument(); + // Switching planner tabs unmounts the sidebar; the filter must come back + // both applied and visible instead of silently sticking on the map only. + unmount(); + render(); + expect(screen.queryByText(/All Categories/i)).not.toBeInTheDocument(); + expect(screen.getByText('Hotel')).toBeInTheDocument(); + expect(screen.getByText('Grand Palace')).toBeInTheDocument(); + expect(screen.queryByText('Street Market')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-SIDEBAR-048: filter tab survives unmount/remount (#1541)', async () => { + const user = userEvent.setup(); + const planned = buildPlace({ name: 'Planned Place' }); + const unplanned = buildPlace({ name: 'Unplanned Place' }); + const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] }; + const { unmount } = render(); + await user.click(screen.getByRole('button', { name: /Unplanned/i })); + expect(screen.queryByText('Planned Place')).not.toBeInTheDocument(); + unmount(); + render(); + expect(screen.queryByText('Planned Place')).not.toBeInTheDocument(); + expect(screen.getByText('Unplanned Place')).toBeInTheDocument(); + }); }); // ── Place list interaction ───────────────────────────────────────────────────── diff --git a/client/src/components/Planner/PlacesSidebarHeader.tsx b/client/src/components/Planner/PlacesSidebarHeader.tsx index 693b0fbf1..e014d9877 100644 --- a/client/src/components/Planner/PlacesSidebarHeader.tsx +++ b/client/src/components/Planner/PlacesSidebarHeader.tsx @@ -23,8 +23,8 @@ export function PlacesHeader(S: SidebarState) { const { canEditPlaces, onAddPlace, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders, places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks, - filter, setFilter, onPlacesFilterChange, setSelectedIds, selectMode, setSelectMode, - catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFiltersLocal, onCategoryFilterChange, + filter, setFilter, setSelectedIds, selectMode, setSelectMode, + catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFilters, } = S return (
@@ -99,7 +99,7 @@ export function PlacesHeader(S: SidebarState) { return (
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 6c2722c44..93e34c612 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -308,8 +308,10 @@ export function useTripPlanner() { useTripWebSocket(tripId) - const [mapCategoryFilter, setMapCategoryFilter] = useState>(new Set()) - const [mapPlacesFilter, setMapPlacesFilter] = useState('all') + // Same filter the places sidebar renders — shared via the store so tab + // switches can't desync the marker set from the filter UI (#1541). + const placesFilter = useTripStore((s) => s.placesFilter) + const placesCategoryFilter = useTripStore((s) => s.placesCategoryFilter) const [expandedDayIds, setExpandedDayIds] = useState | null>(null) @@ -335,23 +337,23 @@ export function useTripPlanner() { } // Build set of planned place IDs for unplanned filter - const plannedIds = mapPlacesFilter === 'unplanned' + const plannedIds = placesFilter === 'unplanned' ? new Set(Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))) : null return places.filter(p => { if (!p.lat || !p.lng) return false - if (mapPlacesFilter === 'tracks' && !p.route_geometry) return false - if (mapCategoryFilter.size > 0) { + if (placesFilter === 'tracks' && !p.route_geometry) return false + if (placesCategoryFilter.size > 0) { if (p.category_id == null) { - if (!mapCategoryFilter.has('uncategorized')) return false - } else if (!mapCategoryFilter.has(String(p.category_id))) return false + if (!placesCategoryFilter.has('uncategorized')) return false + } else if (!placesCategoryFilter.has(String(p.category_id))) return false } if (hiddenPlaceIds.has(p.id)) return false if (plannedIds && plannedIds.has(p.id)) return false return true }) - }, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds]) + }, [places, placesCategoryFilter, placesFilter, assignments, expandedDayIds]) const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations) @@ -915,7 +917,7 @@ export function useTripPlanner() { mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef, deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, visibleConnections, setVisibleConnections, toggleConnection, mapTransportDetail, setMapTransportDetail, - isMobile, isTouch, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter, + isMobile, isTouch, expandedDayIds, setExpandedDayIds, mapPlaces, route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay, handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts index 2597c0996..b5fcf6e87 100644 --- a/client/src/store/tripStore.ts +++ b/client/src/store/tripStore.ts @@ -60,10 +60,17 @@ export interface TripStoreState files: TripFile[] reservations: Reservation[] selectedDayId: number | null + // Places filter (list + map markers). Lives here, not in the sidebar, so the + // applied filter and the filter UI can never drift apart when the Plan tab + // unmounts and remounts (#1541). + placesFilter: string + placesCategoryFilter: Set isLoading: boolean error: string | null setSelectedDay: (dayId: number | null) => void + setPlacesFilter: (filter: string) => void + setPlacesCategoryFilter: (categoryIds: Set) => void handleRemoteEvent: (event: WebSocketEvent) => void resetTrip: () => void loadTrip: (tripId: number | string) => Promise @@ -88,10 +95,14 @@ export const useTripStore = create((set, get) => ({ files: [], reservations: [], selectedDayId: null, + placesFilter: 'all', + placesCategoryFilter: new Set(), isLoading: false, error: null, setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }), + setPlacesFilter: (filter: string) => set({ placesFilter: filter }), + setPlacesCategoryFilter: (categoryIds: Set) => set({ placesCategoryFilter: categoryIds }), handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event), @@ -110,6 +121,8 @@ export const useTripStore = create((set, get) => ({ files: [], reservations: [], selectedDayId: null, + placesFilter: 'all', + placesCategoryFilter: new Set(), error: null, }),