mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(client): let a trip URL open on a given tab
/trips/:id?tab=finanzplan opens the planner straight on that tab. Read in the state initializer, not an effect, so the planner never paints the plan view and swaps a frame later. The param is consumed on arrival; the session memory keeps the choice across a reload. Two things the tab machinery got away with while nothing ever started on a tab other than plan: - the guard evicted anything not in TRIP_TABS, but enabledAddons is an optimistic guess until the addon feed answers, and collab is guessed off - so a requested collab tab was thrown away a render before we knew the addon was on. It now waits for the feed, like it already did for plugins. - the lazy loads hang off handleTabChange, which the tab we *start* on never goes through. Opening on Files showed an empty list, and that was already true for a tab restored from a previous visit. Tab ids move to constants/tripTabs so the settings picker can name them the way the tab bar does instead of keeping a second list.
This commit is contained in:
@@ -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<TripTabId, string> = {
|
||||
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:<id>` 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:')
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<Record<string, boolean>>({ 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<boolean>(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<Accommodation[]>([])
|
||||
const [allowedFileTypes, setAllowedFileTypes] = useState<string | null>(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=<id> 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<string>(() => {
|
||||
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<number | null>(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<Day | null>(null)
|
||||
@@ -171,7 +200,6 @@ export function useTripPlanner() {
|
||||
const [editingPlace, setEditingPlace] = useState<Place | null>(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<number | null>(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<boolean>(false)
|
||||
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
|
||||
|
||||
Reference in New Issue
Block a user