diff --git a/client/src/constants/tripTabs.ts b/client/src/constants/tripTabs.ts new file mode 100644 index 000000000..a5fd8d54e --- /dev/null +++ b/client/src/constants/tripTabs.ts @@ -0,0 +1,50 @@ +/** + * The trip planner's core tab ids, in the order the tab bar shows them. + * + * These are the German legacy names the planner has always used internally + * ('buchungen', 'finanzplan', 'dateien', 'listen') — not the English labels. + * They are what sessionStorage, the ?tab= deep link and the startup-destination + * setting persist, so renaming one would silently strand saved preferences. + * + * Addon-owned tabs are listed here even while their addon is off: useTripPlanner + * builds TRIP_TABS from what is actually enabled, and anything pointing at a tab + * that isn't there falls back to the plan view. + */ +export const TRIP_TAB_IDS = [ + 'plan', + 'transports', + 'buchungen', + 'listen', + 'finanzplan', + 'dateien', + 'collab', +] as const + +export type TripTabId = (typeof TRIP_TAB_IDS)[number] + +/** + * Translation key per tab, so the startup-destination picker in Settings names + * the tabs exactly as the planner's own tab bar does. useTripPlanner builds its + * TRIP_TABS from these too — one source, or the two would drift apart the first + * time a tab is renamed. + */ +export const TRIP_TAB_LABEL_KEYS: Record = { + plan: 'trip.tabs.plan', + transports: 'trip.tabs.transports', + buchungen: 'trip.tabs.reservations', + listen: 'trip.tabs.lists', + finanzplan: 'trip.tabs.budget', + dateien: 'trip.tabs.files', + collab: 'admin.addons.catalog.collab.name', +} + +export const isTripTabId = (value: unknown): value is TripTabId => + typeof value === 'string' && (TRIP_TAB_IDS as readonly string[]).includes(value) + +/** + * What `/trips/:id?tab=…` accepts. Trip-page plugins mount as tabs too and are + * addressable by their `plugin:` form, so a shortcut can point at one — the + * planner's own guard still drops it if that plugin isn't installed. + */ +export const isDeepLinkableTripTab = (value: string): boolean => + isTripTabId(value) || value.startsWith('plugin:') diff --git a/client/src/pages/tripPlanner/useTripPlanner.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.test.tsx index aa711020c..691ac88e0 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.test.tsx @@ -1,4 +1,4 @@ -// FE-TP-HOOK-001 to FE-TP-HOOK-103 +// FE-TP-HOOK-001 to FE-TP-HOOK-110 import React from 'react' import { renderHook, act, waitFor } from '@testing-library/react' import { TranslationProvider } from '../../i18n/TranslationContext' @@ -501,6 +501,85 @@ describe('useTripPlanner — tabs', () => { expect(result.current.activeTab).toBe('plugin:late') }) + + // ── ?tab= deep link (startup destination, shortcuts, wrapper apps) ────────── + + it('FE-TP-HOOK-104: ?tab= opens that tab on the very first render and clears the param', async () => { + vi.mocked(addonsApi.enabled).mockResolvedValue({ addons: [{ id: 'budget' }] }) + searchParams = new URLSearchParams('tab=finanzplan') + seedTrip() + + const { result } = await renderPlanner() + + // No frame on 'plan' first — the tab is right from the initial state. + expect(result.current.activeTab).toBe('finanzplan') + await waitFor(() => expect(searchParams.get('tab')).toBeNull()) + }) + + it('FE-TP-HOOK-105: ?tab= beats the tab the last session ended on', async () => { + sessionStorage.setItem('trip-tab-42', 'buchungen') + searchParams = new URLSearchParams('tab=transports') + seedTrip() + + const { result } = await renderPlanner() + + expect(result.current.activeTab).toBe('transports') + await waitFor(() => expect(sessionStorage.getItem('trip-tab-42')).toBe('transports')) + }) + + it('FE-TP-HOOK-106: a junk ?tab= is ignored rather than rendering a dead panel', async () => { + searchParams = new URLSearchParams('tab=../../etc/passwd') + seedTrip() + + const { result } = await renderPlanner() + + expect(result.current.activeTab).toBe('plan') + }) + + // The lazy loads live in handleTabChange, which a deep link never goes through. + it('FE-TP-HOOK-107: starting on Costs still loads the budget items', async () => { + vi.mocked(addonsApi.enabled).mockResolvedValue({ addons: [{ id: 'budget' }] }) + searchParams = new URLSearchParams('tab=finanzplan') + seedTrip() + + await renderPlanner() + + await waitFor(() => expect(actions.loadBudgetItems).toHaveBeenCalledWith(42)) + }) + + it('FE-TP-HOOK-108: starting on Files still loads the files', async () => { + vi.mocked(addonsApi.enabled).mockResolvedValue({ addons: [{ id: 'documents' }] }) + searchParams = new URLSearchParams('tab=dateien') + seedTrip() + + await renderPlanner() + + await waitFor(() => expect(actions.loadFiles).toHaveBeenCalledWith(42)) + }) + + // enabledAddons is an optimistic guess until the feed answers, and 'collab' + // is guessed off — evicting on that guess would drop a requested tab. + it('FE-TP-HOOK-109: a requested collab tab survives until the addon feed answers', async () => { + vi.mocked(addonsApi.enabled).mockResolvedValue({ addons: [{ id: 'collab' }] }) + searchParams = new URLSearchParams('tab=collab') + seedTrip() + + const { result } = await renderPlanner() + + expect(result.current.activeTab).toBe('collab') + await waitFor(() => expect(result.current.TRIP_TABS.map(t => t.id)).toContain('collab')) + expect(result.current.activeTab).toBe('collab') + }) + + it('FE-TP-HOOK-110: but a tab whose addon really is off still falls back to plan', async () => { + vi.mocked(addonsApi.enabled).mockResolvedValue({ addons: [] }) + searchParams = new URLSearchParams('tab=finanzplan') + seedTrip() + + const { result } = await renderPlanner() + + await waitFor(() => expect(result.current.activeTab).toBe('plan')) + }) }) describe('useTripPlanner — ?create intents', () => { diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index f0e35b4d2..b0d3f13bf 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -27,6 +27,7 @@ import { usePluginStore } from '../../store/pluginStore' import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types' import { DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults' import { resolvePoolAssignmentId } from './tripPlannerModel' +import { isDeepLinkableTripTab, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs' import { isRoutableReservation } from '../../utils/reservationRoutes' import { parseStoredConnections, resolveEffectiveConnections, resolveVisibleConnectionIds, @@ -81,6 +82,10 @@ export function useTripPlanner() { }, [undo, lastActionLabel, toast]) const [enabledAddons, setEnabledAddons] = useState>({ packing: true, budget: true, documents: true, collab: false }) + // The values above are an optimistic guess until the addon feed answers. The + // tab guard below waits for this before evicting anything, so a tab we were + // asked to open ('collab' in particular, guessed off) survives the gap. + const [addonsLoaded, setAddonsLoaded] = useState(false) const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true }) const [tripAccommodations, setTripAccommodations] = useState([]) const [allowedFileTypes, setAllowedFileTypes] = useState(null) @@ -109,7 +114,7 @@ export function useTripPlanner() { data.addons.forEach(a => { map[a.id] = true }) setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab }) if (data.collabFeatures) setCollabFeatures(data.collabFeatures) - }).catch(() => {}) + }).catch(() => {}).finally(() => setAddonsLoaded(true)) authApi.getAppConfig().then(config => { if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) }).catch(() => {}) @@ -124,13 +129,13 @@ export function useTripPlanner() { // them; 'plan' is never replaceable) and may pick where its own tab sits. const replacedTabs = new Set(tripPagePlugins.flatMap(p => p.tripPage?.replaces ?? [])) const TRIP_TABS = [ - { id: 'plan', label: t('trip.tabs.plan'), icon: Map }, - { id: 'transports', label: t('trip.tabs.transports'), icon: Train }, - { id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket }, - ...(enabledAddons.packing ? [{ id: 'listen', label: t('trip.tabs.lists'), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []), - ...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []), - ...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []), - ...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []), + { id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map }, + { id: 'transports', label: t(TRIP_TAB_LABEL_KEYS.transports), icon: Train }, + { id: 'buchungen', label: t(TRIP_TAB_LABEL_KEYS.buchungen), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket }, + ...(enabledAddons.packing ? [{ id: 'listen', label: t(TRIP_TAB_LABEL_KEYS.listen), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []), + ...(enabledAddons.budget ? [{ id: 'finanzplan', label: t(TRIP_TAB_LABEL_KEYS.finanzplan), icon: Wallet }] : []), + ...(enabledAddons.documents ? [{ id: 'dateien', label: t(TRIP_TAB_LABEL_KEYS.dateien), icon: FolderOpen }] : []), + ...(enabledAddons.collab ? [{ id: 'collab', label: t(TRIP_TAB_LABEL_KEYS.collab), icon: Users }] : []), ].filter(tab => tab.id === 'plan' || !replacedTabs.has(tab.id)) // Positioned plugin tabs splice in ascending order so two positions stay stable; // the rest append, exactly as before this capability existed. @@ -138,20 +143,31 @@ export function useTripPlanner() { for (const p of positioned) TRIP_TABS.splice(Math.min(p.tripPage!.position!, TRIP_TABS.length), 0, { id: `plugin:${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) }) for (const p of tripPagePlugins.filter(p => p.tripPage?.position == null)) TRIP_TABS.push({ id: `plugin:${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) }) + const [searchParams, setSearchParams] = useSearchParams() + + // ?tab= opens the trip straight on that tab (the startup destination + // setting, a browser shortcut, a wrapper app). It beats the session's last + // tab because it is an explicit request for this one, and it is read in the + // initializer rather than an effect so the planner never paints the plan view + // first and swaps a frame later. const [activeTab, setActiveTab] = useState(() => { - const saved = sessionStorage.getItem(`trip-tab-${tripId}`) - return saved || 'plan' + const requested = searchParams.get('tab') + if (requested && isDeepLinkableTripTab(requested)) return requested + return sessionStorage.getItem(`trip-tab-${tripId}`) || 'plan' }) useEffect(() => { // Don't evict a saved plugin tab before the plugin feed has loaded. if (activeTab.startsWith('plugin:') && !pluginsLoaded) return + // Same for the addon-owned tabs: until the feed answers, enabledAddons is a + // guess, and evicting on a guess would drop a legitimately requested tab. + if (!addonsLoaded) return const validTabIds = TRIP_TABS.map(t => t.id) if (!validTabIds.includes(activeTab)) { setActiveTab('plan') sessionStorage.setItem(`trip-tab-${tripId}`, 'plan') } - }, [activeTab, enabledAddons, tripPluginIds, pluginsLoaded]) + }, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded]) const handleTabChange = (rawTabId: string): void => { // A core tab a plugin replaced is gone from the bar, but a programmatic jump @@ -163,6 +179,19 @@ export function useTripPlanner() { if (tabId === 'finanzplan') tripActions.loadBudgetItems?.(tripId) if (tabId === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId) } + + // handleTabChange is where a tab's lazy load and its session memory happen, and + // the tab we *start* on never goes through it — neither a ?tab= deep link nor a + // tab restored from a previous visit. Catch both up once per trip, or opening + // straight into Files shows an empty list. + const startTabSettled = useRef(null) + useEffect(() => { + if (!tripId || startTabSettled.current === tripId) return + startTabSettled.current = tripId + sessionStorage.setItem(`trip-tab-${tripId}`, activeTab) + if (activeTab === 'finanzplan') tripActions.loadBudgetItems?.(tripId) + if (activeTab === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId) + }, [tripId]) const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels() const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection() const [showDayDetail, setShowDayDetail] = useState(null) @@ -171,7 +200,6 @@ export function useTripPlanner() { const [editingPlace, setEditingPlace] = useState(null) const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null>(null) const [editingAssignmentId, setEditingAssignmentId] = useState(null) - const [searchParams, setSearchParams] = useSearchParams() // The bottom-nav "+" opens the new-place form via ?create=place. useEffect(() => { @@ -180,6 +208,14 @@ export function useTripPlanner() { setSearchParams(p => { p.delete('create'); return p }, { replace: true }) } }, [searchParams]) + + // ?tab= has done its job in the state initializer above — drop it so the URL + // stops claiming a tab the user may have since switched away from. The session + // memory keeps the choice across a reload. + useEffect(() => { + if (searchParams.get('tab') === null) return + setSearchParams(p => { p.delete('tab'); return p }, { replace: true }) + }, [searchParams]) const [showTripForm, setShowTripForm] = useState(false) const [showMembersModal, setShowMembersModal] = useState(false) const [showReservationModal, setShowReservationModal] = useState(false)