From 7d8dcb02c8784f3e8633915758d177abc2b50315 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 01:07:16 +0000 Subject: [PATCH] refactor(client): the lists tab moves out of the planner page ListsContainer, the packing and to-do subtabs of the lists tab, moves unchanged to pages/tripPlanner/ListsContainer.tsx, taking along the imports only it used. The page renders it exactly as before. --- client/src/pages/TripPlannerPage.tsx | 153 +----------------- .../pages/tripPlanner/ListsContainer.test.tsx | 123 ++++++++++++++ .../src/pages/tripPlanner/ListsContainer.tsx | 152 +++++++++++++++++ 3 files changed, 279 insertions(+), 149 deletions(-) create mode 100644 client/src/pages/tripPlanner/ListsContainer.test.tsx create mode 100644 client/src/pages/tripPlanner/ListsContainer.tsx diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index c185b45aa..31758fc4a 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -2,7 +2,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 { useCanDo } from '../store/permissionsStore' import { useSettingsStore } from '../store/settingsStore' import { MapViewAuto as MapView } from '../components/Map/MapViewAuto' import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill' @@ -28,8 +27,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 ApplyTemplateButton from '../components/Packing/ApplyTemplateButton' -import PackingExportMenu from '../components/Packing/PackingExportMenu' import type { ExpensePrefill } from '../components/Budget/CostsPanel' import { expenseEditorFor } from '../components/Budget/CostsPanel.helpers' import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types' @@ -42,8 +39,7 @@ import Navbar from '../components/Layout/Navbar' import HelpAnchor from '../components/Help/HelpAnchor' import { getHelpContext } from '../help/registry' import { useToast } from '../components/shared/Toast' -import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react' -import { useTranslation } from '../i18n' +import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, Wallet, FolderOpen, Users, Train } from 'lucide-react' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client' import { accommodationRepo } from '../repo/accommodationRepo' import { useAuthStore } from '../store/authStore' @@ -53,161 +49,20 @@ import { useTripWebSocket } from '../hooks/useTripWebSocket' import { useRouteCalculation } from '../hooks/useRouteCalculation' import { usePlaceSelection } from '../hooks/usePlaceSelection' import { usePlannerHistory } from '../hooks/usePlannerHistory' -import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types' -import { ListTodo, ListPlus, Download, Plus, FolderPlus } from 'lucide-react' +import type { Accommodation, TripMember, Day, Place, Reservation } from '../types' import { useTripPlannerPage } from './tripPlanner/useTripPlannerPage' import { - ReservationsPanel, PackingListPanel, TodoListPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel, + ReservationsPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel, RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal, RoadtripAlternativesBar, TourPlannerRail, TourPlannerToursRail, TransportModal, } from './tripPlanner/plannerLazy' import { LazyPanel } from './tripPlanner/LazyPanel' +import { ListsContainer } from './tripPlanner/ListsContainer' import { useMergedMapPois } from '../components/Map/useMergedMapPois' import PoiCategoryPill from '../components/Map/PoiCategoryPill' import { useTouchDragBridge } from '../hooks/useTouchDragBridge' import PanelResizeHandle from '../components/Planner/PanelResizeHandle' -function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) { - const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => { - return (sessionStorage.getItem(`trip-lists-subtab-${tripId}`) as 'packing' | 'todo') || 'packing' - }) - const setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) } - const [importPackingSignal, setImportPackingSignal] = useState(0) - const [addCategorySignal, setAddCategorySignal] = useState(0) - const [saveTemplateSignal, setSaveTemplateSignal] = useState(0) - const [addTodoSignal, setAddTodoSignal] = useState(0) - const [packingView, setPackingView] = useState<'common' | 'personal'>('common') - const { t } = useTranslation() - const isAdmin = useAuthStore(s => s.user?.role === 'admin') - const trip = useTripStore(s => s.trip) - const canEditPacking = useCanDo()('packing_edit', trip) - - const tabs = [ - { id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length }, - { id: 'todo' as const, label: t('todo.subtab.todo'), icon: ListTodo, count: todoItems.length }, - ] - - // The to-do view fills what is left under the bar, so its list and detail pane - // scroll inside the screen and the pane's buttons stay in sight. - const fill = subTab === 'todo' - return ( -
-
-
-

- {t('trip.tabs.lists')} -

-
-
- {tabs.map(tab => { - const active = subTab === tab.id - const Icon = tab.icon - return ( - - ) - })} -
- - {subTab === 'packing' && (() => { - const sharedBtnClass = 'inline-flex items-center gap-1.5 px-2.5 sm:px-[14px] py-[7px] sm:py-[9px] hover:opacity-[0.88]' - // Export and Import carry only their icon, with the name as tooltip and label. - const iconBtnClass = 'inline-flex items-center justify-center px-2.5 py-[7px] sm:py-[9px] hover:opacity-[0.88] bg-accent text-accent-text' - const sharedBtnStyle: React.CSSProperties = { - appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', - borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, - } - return ( -
- {canEditPacking && ( - - )} - - {isAdmin && packingItems.length > 0 && ( - - )} - - -
- ) - })()} - {subTab === 'todo' && ( - - )} -
-
-
- {subTab === 'packing' && ( - - - - )} - {subTab === 'todo' && ( - - - - )} -
-
- ) -} - /** The tab ids are historical; the help screens carry the names the tabs show. */ const TRIP_TAB_HELP: Record = { transports: 'transports', buchungen: 'bookings', listen: 'lists', finanzplan: 'costs', dateien: 'files', collab: 'collab', roadtrip: 'roadtrip', diff --git a/client/src/pages/tripPlanner/ListsContainer.test.tsx b/client/src/pages/tripPlanner/ListsContainer.test.tsx new file mode 100644 index 000000000..f0d892c5e --- /dev/null +++ b/client/src/pages/tripPlanner/ListsContainer.test.tsx @@ -0,0 +1,123 @@ +// FE-PAGE-PLANNER-LISTS-001 to FE-PAGE-PLANNER-LISTS-006 +import { render, screen, fireEvent, act } from '../../../tests/helpers/render' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { buildUser, buildTrip, buildPackingItem, buildTodoItem } from '../../../tests/helpers/factories' +import { useAuthStore } from '../../store/authStore' +import { useTripStore } from '../../store/tripStore' +import { usePermissionsStore, type PermissionLevel } from '../../store/permissionsStore' +import { ListsContainer } from './ListsContainer' + +// The panels and header tools are stubs that record what the container hands them. +const captured = vi.hoisted(() => ({} as Record>)) +const stub = vi.hoisted(() => (name: string) => (props: Record) => { + captured[name] = props + return null +}) +vi.mock('../../components/Packing/PackingListPanel', () => ({ default: stub('packingPanel') })) +vi.mock('../../components/Todo/TodoListPanel', () => ({ default: stub('todoPanel') })) +vi.mock('../../components/Packing/ApplyTemplateButton', () => ({ default: stub('applyTemplate') })) +vi.mock('../../components/Packing/PackingExportMenu', () => ({ default: stub('exportMenu') })) + +/** The props a lazily loaded panel was last rendered with, once its chunk is in. */ +function loaded>(name: string): Promise { + return vi.waitFor(() => { + if (!captured[name]) throw new Error(`${name} has not rendered yet`) + return captured[name] as T + }) +} + +const TRIP_ID = 31 + +function seed({ role = 'user', packingPermission }: { role?: 'user' | 'admin'; packingPermission?: PermissionLevel } = {}) { + seedStore(useAuthStore, { user: buildUser({ id: 9, role }) }) + // A member, not the owner: owner-only rights then really are withheld. + seedStore(useTripStore, { trip: buildTrip({ id: TRIP_ID, user_id: 1 }) }) + seedStore(usePermissionsStore, { permissions: packingPermission ? { packing_edit: packingPermission } : {} }) +} + +function renderLists(packing = [buildPackingItem(), buildPackingItem()], todos = [buildTodoItem()]) { + return render() +} + +beforeEach(() => { + resetAllStores() + sessionStorage.clear() + for (const key of Object.keys(captured)) delete captured[key] +}) + +describe('ListsContainer', () => { + it('FE-PAGE-PLANNER-LISTS-001: opens on the packing list and counts both lists', async () => { + seed() + renderLists() + + expect(screen.getByRole('heading', { name: 'Lists' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Packing List\s*2/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /To-Do\s*1/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add list' })).toBeInTheDocument() + expect(await loaded('packingPanel')).toMatchObject({ tripId: TRIP_ID, inlineHeader: false, view: 'common' }) + expect(captured.todoPanel).toBeUndefined() + }) + + it('FE-PAGE-PLANNER-LISTS-002: the chosen subtab is remembered per trip for the session', async () => { + seed() + sessionStorage.setItem('trip-lists-subtab-99', 'todo') + renderLists() + // Another trip's choice does not carry over. + expect(screen.queryByRole('button', { name: 'Add new task' })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: /To-Do/ })) + expect(sessionStorage.getItem(`trip-lists-subtab-${TRIP_ID}`)).toBe('todo') + expect(await loaded('todoPanel')).toMatchObject({ tripId: TRIP_ID, addItemSignal: 0 }) + expect(screen.queryByRole('button', { name: 'Add list' })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: /Packing List/ })) + expect(sessionStorage.getItem(`trip-lists-subtab-${TRIP_ID}`)).toBe('packing') + }) + + it('FE-PAGE-PLANNER-LISTS-003: each press of Add new task raises the to-do signal once more', async () => { + seed() + sessionStorage.setItem(`trip-lists-subtab-${TRIP_ID}`, 'todo') + renderLists() + + const add = screen.getByRole('button', { name: 'Add new task' }) + fireEvent.click(add) + fireEvent.click(add) + expect(await loaded('todoPanel')).toMatchObject({ addItemSignal: 2 }) + }) + + it('FE-PAGE-PLANNER-LISTS-004: without the packing right there is no Add list, the other tools stay', async () => { + seed({ packingPermission: 'trip_owner' }) + renderLists() + + expect(screen.queryByRole('button', { name: 'Add list' })).toBeNull() + expect(screen.getByRole('button', { name: 'Import' })).toBeInTheDocument() + await loaded('packingPanel') + fireEvent.click(screen.getByRole('button', { name: 'Import' })) + expect(captured.packingPanel).toMatchObject({ openImportSignal: 1, addCategorySignal: 0 }) + }) + + it('FE-PAGE-PLANNER-LISTS-005: an admin saves a template only when the list has items', async () => { + seed({ role: 'admin' }) + const { unmount } = renderLists([]) + expect(screen.queryByRole('button', { name: 'Save as template' })).toBeNull() + unmount() + + renderLists() + fireEvent.click(screen.getByRole('button', { name: 'Save as template' })) + fireEvent.click(screen.getByRole('button', { name: 'Add list' })) + expect(await loaded('packingPanel')).toMatchObject({ saveTemplateSignal: 1, addCategorySignal: 1 }) + }) + + it('FE-PAGE-PLANNER-LISTS-006: the panel switches the view the template and export tools work on', async () => { + seed() + renderLists() + const panel = await loaded<{ onViewChange: (v: string) => void }>('packingPanel') + expect(captured.applyTemplate).toMatchObject({ tripId: TRIP_ID, visibility: 'common' }) + expect(captured.exportMenu).toMatchObject({ tripId: TRIP_ID, view: 'common' }) + + act(() => { panel.onViewChange('personal') }) + expect(captured.applyTemplate).toMatchObject({ visibility: 'personal' }) + expect(captured.exportMenu).toMatchObject({ view: 'personal' }) + expect(captured.packingPanel).toMatchObject({ view: 'personal' }) + }) +}) diff --git a/client/src/pages/tripPlanner/ListsContainer.tsx b/client/src/pages/tripPlanner/ListsContainer.tsx new file mode 100644 index 000000000..3ca7ceed8 --- /dev/null +++ b/client/src/pages/tripPlanner/ListsContainer.tsx @@ -0,0 +1,152 @@ +import React, { useState } from 'react' +import { PackageCheck, ListTodo, ListPlus, Download, Plus, FolderPlus } from 'lucide-react' +import ApplyTemplateButton from '../../components/Packing/ApplyTemplateButton' +import PackingExportMenu from '../../components/Packing/PackingExportMenu' +import { useTranslation } from '../../i18n' +import { useAuthStore } from '../../store/authStore' +import { useTripStore } from '../../store/tripStore' +import { useCanDo } from '../../store/permissionsStore' +import type { PackingItem, TodoItem } from '../../types' +import { PackingListPanel, TodoListPanel } from './plannerLazy' +import { LazyPanel } from './LazyPanel' + +export function ListsContainer({ tripId, packingItems, todoItems }: Readonly<{ tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }>) { + const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => { + return (sessionStorage.getItem(`trip-lists-subtab-${tripId}`) as 'packing' | 'todo') || 'packing' + }) + const setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) } + const [importPackingSignal, setImportPackingSignal] = useState(0) + const [addCategorySignal, setAddCategorySignal] = useState(0) + const [saveTemplateSignal, setSaveTemplateSignal] = useState(0) + const [addTodoSignal, setAddTodoSignal] = useState(0) + const [packingView, setPackingView] = useState<'common' | 'personal'>('common') + const { t } = useTranslation() + const isAdmin = useAuthStore(s => s.user?.role === 'admin') + const trip = useTripStore(s => s.trip) + const canEditPacking = useCanDo()('packing_edit', trip) + + const tabs = [ + { id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length }, + { id: 'todo' as const, label: t('todo.subtab.todo'), icon: ListTodo, count: todoItems.length }, + ] + + // The to-do view fills what is left under the bar, so its list and detail pane + // scroll inside the screen and the pane's buttons stay in sight. + const fill = subTab === 'todo' + return ( +
+
+
+

+ {t('trip.tabs.lists')} +

+
+
+ {tabs.map(tab => { + const active = subTab === tab.id + const Icon = tab.icon + return ( + + ) + })} +
+ + {subTab === 'packing' && (() => { + const sharedBtnClass = 'inline-flex items-center gap-1.5 px-2.5 sm:px-[14px] py-[7px] sm:py-[9px] hover:opacity-[0.88]' + // Export and Import carry only their icon, with the name as tooltip and label. + const iconBtnClass = 'inline-flex items-center justify-center px-2.5 py-[7px] sm:py-[9px] hover:opacity-[0.88] bg-accent text-accent-text' + const sharedBtnStyle: React.CSSProperties = { + appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', + borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, + } + return ( +
+ {canEditPacking && ( + + )} + + {isAdmin && packingItems.length > 0 && ( + + )} + + +
+ ) + })()} + {subTab === 'todo' && ( + + )} +
+
+
+ {subTab === 'packing' && ( + + + + )} + {subTab === 'todo' && ( + + + + )} +
+
+ ) +}