From c2ba78253d78f17ecc72607ebc559855040bb975 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 13 Nov 2026 12:40:17 +0100 Subject: [PATCH] fix(client): lint, theme and size ratchets of the quick-win round, and the tests that followed the features New warnings fixed at their source (refs written in effects, typed tile options, no stray imports, smaller functions and components), the payer and split heads hold their switch instead of letting it overhang, the baselines lowered where the round removed warnings, and the tests updated to the solo trip, the shares view and the revisit status. --- client/scripts/eslint-baseline.json | 10 +- client/scripts/rtl-baseline.json | 2 +- client/scripts/size-baseline.json | 6 +- client/scripts/theme-baseline.json | 4 +- .../Budget/CostsCategoryBreakdown.tsx | 23 ++++ .../src/components/Budget/CostsPanel.test.tsx | 11 +- client/src/components/Budget/CostsPanel.tsx | 15 +-- .../src/components/Budget/SettlementModal.tsx | 2 +- .../components/Budget/useCostsMode.test.ts | 15 +-- .../Collections/CollectionFilterBar.test.tsx | 2 +- .../components/Journey/JourneyMap.test.tsx | 4 +- client/src/components/Journey/JourneyMap.tsx | 106 ++++++++++-------- .../src/components/Journey/JourneyMapGL.tsx | 5 +- client/src/components/PDF/TripPDF.test.ts | 39 ++++--- .../Packing/usePackingCategorySharing.test.ts | 11 +- .../src/components/Planner/PlaceInspector.tsx | 28 ++--- .../components/Settings/MapSettingsTab.tsx | 10 +- .../Settings/useMapSettingsForm.test.ts | 6 + .../components/Settings/useMapSettingsForm.ts | 6 + .../Tours/TourDetailDialog.test.tsx | 14 ++- .../src/components/Tours/TourDetailDialog.tsx | 36 +----- .../components/Tours/TourRouteAppsButton.tsx | 49 ++++++++ .../components/Tours/tourRouteApps.test.ts | 9 +- client/src/components/shared/categoryIcons.ts | 2 +- client/src/components/shared/usePlacePhoto.ts | 21 +++- .../screens/admin/MAdminSettingsSection.tsx | 2 +- .../mobile/screens/settings/MSettingsMap.tsx | 9 +- .../tabs/MPackingCategorySharingSheet.tsx | 14 +-- client/src/pages/CollectionsPage.test.tsx | 2 +- .../src/pages/CollectionsPage.wiring.test.tsx | 2 +- client/src/pages/admin/AdminSettingsTab.tsx | 2 +- .../pages/collections/useCollections.test.tsx | 2 +- .../tests/unit/mobile/trip/MCostsTab.test.tsx | 3 +- 33 files changed, 278 insertions(+), 194 deletions(-) create mode 100644 client/src/components/Tours/TourRouteAppsButton.tsx diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index 937ce8279..4cc7018ee 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -1,10 +1,10 @@ { "src": { "@typescript-eslint/no-non-null-assertion": 375, - "@typescript-eslint/no-misused-promises": 842, + "@typescript-eslint/no-misused-promises": 840, "no-console": 88, "complexity": 218, - "@typescript-eslint/no-explicit-any": 144, + "@typescript-eslint/no-explicit-any": 143, "trek/no-swallowed-catch": 128, "max-params": 31, "trek/store-without-selector": 67, @@ -12,7 +12,7 @@ "react-hooks/set-state-in-effect": 192, "@typescript-eslint/no-floating-promises": 163, "react-hooks/exhaustive-deps": 114, - "@typescript-eslint/no-unused-vars": 123, + "@typescript-eslint/no-unused-vars": 122, "react-hooks/immutability": 28, "max-lines-per-function": 130, "react-hooks/static-components": 50, @@ -35,10 +35,10 @@ }, "tests": { "@typescript-eslint/no-explicit-any": 489, - "@typescript-eslint/no-non-null-assertion": 1911, + "@typescript-eslint/no-non-null-assertion": 1907, "@typescript-eslint/no-misused-promises": 22, "@typescript-eslint/no-unused-vars": 51, - "@typescript-eslint/no-floating-promises": 33, + "@typescript-eslint/no-floating-promises": 27, "trek/no-window-globals": 325, "trek/no-swallowed-catch": 14, "react-hooks/globals": 5, diff --git a/client/scripts/rtl-baseline.json b/client/scripts/rtl-baseline.json index b692cfc21..4af5b800d 100644 --- a/client/scripts/rtl-baseline.json +++ b/client/scripts/rtl-baseline.json @@ -63,7 +63,7 @@ "components/Vacay/VacayCalendar.tsx": 3, "components/Vacay/VacayMonthCard.tsx": 7, "components/Vacay/VacaySettings.tsx": 1, - "components/shared/ContextMenu.tsx": 3, + "components/shared/ContextMenu.tsx": 1, "components/shared/CustomDateTimePicker.tsx": 2, "components/shared/CustomSelect.tsx": 4, "components/shared/CustomTimePicker.tsx": 3, diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 978a06062..5b589778c 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -3,7 +3,7 @@ "src/api/client.ts": 1873, "src/components/Admin/AdminPluginsPanel.test.tsx": 2704, "src/components/Admin/AdminPluginsPanel.tsx": 1266, - "src/components/Budget/CostsPanel.test.tsx": 2730, + "src/components/Budget/CostsPanel.test.tsx": 2725, "src/components/Budget/CostsPanel.tsx": 1607, "src/components/Map/MapView.tsx": 1200, "src/components/Map/MapViewGL.test.tsx": 2919, @@ -12,7 +12,7 @@ "src/components/Planner/DayPlanSidebar.test.tsx": 5349, "src/components/Planner/DayPlanSidebar.tsx": 2910, "src/components/Planner/PlaceFormModal.test.tsx": 2836, - "src/components/Planner/PlaceInspector.tsx": 1129, + "src/components/Planner/PlaceInspector.tsx": 1128, "src/components/Studio/TravelElements.tsx": 1559, "src/components/Studio/countryShapes.ts": 2595, "src/index.css": 1636, @@ -22,6 +22,6 @@ "src/pages/TripPlannerPage.tsx": 1128, "src/pages/atlas/useAtlas.ts": 1207, "src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518, - "src/pages/tripPlanner/useTripPlanner.test.tsx": 3270, + "src/pages/tripPlanner/useTripPlanner.test.tsx": 3269, "src/styles/studio.css": 1918 } diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json index af357029a..5061ad14f 100644 --- a/client/scripts/theme-baseline.json +++ b/client/scripts/theme-baseline.json @@ -110,7 +110,7 @@ "components/SystemNotices/SystemNoticeModal.tsx": 107, "components/Todo/TodoListPanel.tsx": 6, "components/Todo/todoListModel.ts": 3, - "components/Trips/TripFormModal.tsx": 9, + "components/Trips/TripFormModal.tsx": 6, "components/Vacay/VacayBadge.tsx": 3, "components/Vacay/VacayCalendar.tsx": 20, "components/Vacay/VacayMonthCard.tsx": 5, @@ -250,7 +250,7 @@ "mobile/screens/trip/sheets/MPlaceSheet.tsx": 23, "mobile/screens/trip/sheets/MReservationSheet.tsx": 2, "mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9, - "mobile/screens/trip/sheets/MTransportSheet.tsx": 12, + "mobile/screens/trip/sheets/MTransportSheet.tsx": 9, "mobile/screens/trip/sheets/MTripSheetUi.tsx": 5, "mobile/screens/trip/sheets/PlCategoryPicker.tsx": 2, "mobile/screens/trip/sheets/PlFileAttach.tsx": 7, diff --git a/client/src/components/Budget/CostsCategoryBreakdown.tsx b/client/src/components/Budget/CostsCategoryBreakdown.tsx index 359f466cd..2d106cba4 100644 --- a/client/src/components/Budget/CostsCategoryBreakdown.tsx +++ b/client/src/components/Budget/CostsCategoryBreakdown.tsx @@ -99,3 +99,26 @@ export function CostsCategoryBreakdown({ ); } + +/** + * What the totals leave out, beside the total (#2819), and the way back: a click + * counts every category again. The table view has no breakdown to undo it in. + */ +export function LeftOutChip({ + label, + onCountAll, + className, +}: { + label: string; + onCountAll: () => void; + className: string; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 4967e250e..aff01472f 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -1780,20 +1780,15 @@ describe('CostsPanel — remaining paths', () => { expect(screen.getAllByText('XBT').length).toBeGreaterThanOrEqual(2) }) - it('FE-W5COSTS-049: a solo trip defaults the payment counterpart to myself', async () => { - const user = userEvent.setup() + it('FE-W5COSTS-049: a solo trip offers no payment, there is nobody to pay (#2689)', async () => { server.use( http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })), http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), ) render() - await user.click(await screen.findByRole('button', { name: 'Add payment' })) - await user.type(await screen.findByPlaceholderText('0,00'), '10') - - // From and To both resolve to me, so the transfer stays unsavable. - const submits = screen.getAllByRole('button', { name: 'Add payment' }) - expect(submits[submits.length - 1]).toBeDisabled() + expect(await screen.findByRole('button', { name: 'Add expense' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Add payment' })).not.toBeInTheDocument() }) it('FE-W5COSTS-050: the expense modal shows uploaded avatars and rejects over-precise input', async () => { diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 0c52841de..c23fdbee1 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -1,5 +1,5 @@ import { Fragment, useState, useMemo, useCallback, useId, type ReactNode } from 'react' -import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, ScanLine, Users } from 'lucide-react' +import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, ChevronDown, Receipt, ScanLine, Users } from 'lucide-react' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useToast } from '../shared/Toast' @@ -31,7 +31,7 @@ import { useExpenseDeleteConfirm } from './useExpenseDeleteConfirm' import { FlowPills } from './FlowPills' import { CostsSplitChips } from './CostsSplitChips' import { CostsModeCard } from './CostsModeCard' -import { CostsCategoryBreakdown } from './CostsCategoryBreakdown' +import { CostsCategoryBreakdown, LeftOutChip } from './CostsCategoryBreakdown' import { CostsCurrencyPicker } from './CostsCurrencyPicker' import type { BudgetParticipantFinal, ReceiptLine } from '@trek/shared' import type { BudgetItem, BudgetItemReceipt } from '../../types' @@ -243,12 +243,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {t('costs.yourShare')}{fmt0(totals.myShare)} {t('costs.youPaid')}{fmt0(totals.myPaid)} : {t('costs.expensesCount', { count: countedCount })}} - {/* Also the way back where the breakdown is not on show (the table view). */} - {leftOutLabel && ( - - - - )}} /> + {leftOutLabel && }} /> ) // Spend by category; a click leaves one out of the totals for a look without it (#2819). @@ -1063,7 +1058,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const payerSection = ( label={t('costs.whoPaid')} @@ -1137,7 +1132,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const splitSection = ( label={t('costs.split') || 'Split'} diff --git a/client/src/components/Budget/SettlementModal.tsx b/client/src/components/Budget/SettlementModal.tsx index 85ff8ae6c..6026386c2 100644 --- a/client/src/components/Budget/SettlementModal.tsx +++ b/client/src/components/Budget/SettlementModal.tsx @@ -105,7 +105,7 @@ export function SettlementModal({ {t('common.cancel')} - + void save()} disabled={!valid || saving}> {editing ? t('common.save') : t('costs.addPayment')} diff --git a/client/src/components/Budget/useCostsMode.test.ts b/client/src/components/Budget/useCostsMode.test.ts index de6577900..276a086b4 100644 --- a/client/src/components/Budget/useCostsMode.test.ts +++ b/client/src/components/Budget/useCostsMode.test.ts @@ -1,14 +1,16 @@ // FE-BUDGET-COSTSMODE-001 to FE-BUDGET-COSTSMODE-003 import { act, renderHook } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { buildTrip } from '../../../tests/helpers/factories'; import { resetAllStores } from '../../../tests/helpers/store'; import { useTripStore, type TripStoreState } from '../../store/tripStore'; import { useCostsMode } from './useCostsMode'; +const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); + let updateTrip: ReturnType; -let addToast: ReturnType; const trip = (costsMode?: 'split' | 'totals') => buildTrip({ id: 7, ...(costsMode ? { costs_mode: costsMode } : {}) }); @@ -16,12 +18,7 @@ beforeEach(() => { resetAllStores(); updateTrip = vi.fn(async () => buildTrip({ id: 7 })); useTripStore.setState({ updateTrip } as unknown as Partial); - addToast = vi.fn(); - (window as unknown as { __addToast: unknown }).__addToast = addToast; -}); - -afterEach(() => { - delete (window as unknown as { __addToast?: unknown }).__addToast; + toast.error.mockClear(); }); describe('useCostsMode (#1714)', () => { @@ -50,7 +47,7 @@ describe('useCostsMode (#1714)', () => { await result.current.setSplitting(true); }); expect(updateTrip).toHaveBeenLastCalledWith(7, { costs_mode: 'split' }); - expect(addToast.mock.calls.map((c) => c[1])).toContain('error'); + expect(toast.error).toHaveBeenCalled(); expect(result.current.saving).toBe(false); }); }); diff --git a/client/src/components/Collections/CollectionFilterBar.test.tsx b/client/src/components/Collections/CollectionFilterBar.test.tsx index 3a12bcbf2..f7f7d42a9 100644 --- a/client/src/components/Collections/CollectionFilterBar.test.tsx +++ b/client/src/components/Collections/CollectionFilterBar.test.tsx @@ -24,7 +24,7 @@ const CATEGORY_OPTIONS: CategoryOption[] = [ function makeProps(overrides: Partial = {}): HarnessProps { return { statusFilter: 'all' as StatusFilter, - counts: { all: 3, idea: 1, want: 1, visited: 1 }, + counts: { all: 3, idea: 1, want: 1, visited: 1, revisit: 0 }, categoryFilter: 'all', categoryOptions: CATEGORY_OPTIONS, ratingFilter: 'all', diff --git a/client/src/components/Journey/JourneyMap.test.tsx b/client/src/components/Journey/JourneyMap.test.tsx index 019391519..71977096c 100644 --- a/client/src/components/Journey/JourneyMap.test.tsx +++ b/client/src/components/Journey/JourneyMap.test.tsx @@ -683,8 +683,8 @@ describe('JourneyMap', () => { const { rerender } = render(); const vector = await waitFor(() => { const layer = lastGlLayer(); - expect(layer).toBeTruthy(); - return layer!; + if (!layer) throw new Error('no GL layer yet'); + return layer; }); rerender(); diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 4dd2c03cc..dc85f653b 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -307,7 +307,7 @@ function fitOptions(paddingBottom: number | undefined, paddingStart = 0): L.FitB */ function rasterBase(url: string, satellite: boolean, gcj: boolean): L.TileLayer { const tileUrl = satellite ? (gcj ? AMAP_SATELLITE : SATELLITE_TILE_URL) : url - return L.tileLayer(tileUrl, { + const options: L.TileLayerOptions & { referrerPolicy: ReferrerPolicy } = { maxZoom: satellite ? SATELLITE_TILE_MAXZOOM : 18, attribution: attributionForTile(tileUrl), referrerPolicy: 'strict-origin-when-cross-origin', @@ -317,7 +317,65 @@ function rasterBase(url: string, satellite: boolean, gcj: boolean): L.TileLayer // updates and keep a larger ring of off-screen tiles ready. updateWhenIdle: false, keepBuffer: 4, - } as any) + } + return L.tileLayer(tileUrl, options) +} + +interface BaseRefs { + mapRef: RefObject + tileUrlRef: RefObject + tileLayerRef: RefObject + glLayerRef: RefObject +} + +/** + * The map's base and the reader's switch between it and the satellite imagery + * (#2594). The map effect draws it through drawBase; the switch redraws it in + * place, since a rebuild would drop every marker and track for a new backdrop. + */ +function useJourneyBase(satellite: boolean, { mapRef, tileUrlRef, tileLayerRef, glLayerRef }: BaseRefs) { + const satelliteRef = useRef(satellite) + useLayoutEffect(() => { + satelliteRef.current = satellite + }) + // Bumped by every base drawn, so a vector style still loading for a base the + // reader has already switched away from never lands on the map. + const baseDrawRef = useRef(0) + // Which base the map carries now, so the switch redraws only on a change. + const drawnSatelliteRef = useRef(satellite) + + /** + * Draws the base: the satellite imagery while the reader asked for it, + * otherwise the basemap, which is a vector style unless the user brought their + * own raster template. Whatever drew it before is taken off first. A vector + * style is a GL canvas of its own, so leaving it under the imagery would keep + * a WebGL context busy drawing what nobody can see. + */ + const drawBase = useCallback((map: L.Map, torn: () => boolean) => { + const draw = ++baseDrawRef.current + drawnSatelliteRef.current = satelliteRef.current + tileLayerRef.current?.remove() + tileLayerRef.current = null + detachBasemapLayer(glLayerRef.current) + glLayerRef.current = null + const url = tileUrlRef.current + if (!satelliteRef.current && isVectorStyle(url)) { + void attachVectorBasemap(map, url, glLayerRef, () => torn() || draw !== baseDrawRef.current) + return + } + const tiles = rasterBase(url, satelliteRef.current, !isVectorStyle(url) && isGcj02Basemap(url)) + tiles.addTo(map) + tileLayerRef.current = tiles + }, [tileUrlRef, tileLayerRef, glLayerRef]) + + // Nothing to do when the map effect has just drawn the base the switch asks for. + useEffect(() => { + const map = mapRef.current + if (!map || drawnSatelliteRef.current === satellite) return + drawBase(map, () => mapRef.current !== map) + }, [satellite, drawBase, mapRef]) + + return { drawBase, satelliteRef } } function JourneyMap( @@ -350,18 +408,9 @@ function JourneyMap( const tileLayerRef = useRef(null) // GL layer or the raster stand-in a browser without WebGL gets instead (#2288). const glLayerRef = useRef(null) - // Read by the map effect like the tile url, and switched in place by its own - // effect below: a rebuild would drop every marker and track for a new backdrop. - const satellite = baseLayer === 'satellite' - const satelliteRef = useRef(satellite) - satelliteRef.current = satellite - // Bumped by every base drawn, so a vector style still loading for a base the - // reader has already switched away from never lands on the map. - const baseDrawRef = useRef(0) - // Which base the map carries now, so the switch effect redraws only on a change. - const drawnSatelliteRef = useRef(satellite) const containerRef = useRef(null) const mapRef = useRef(null) + const { drawBase, satelliteRef } = useJourneyBase(baseLayer === 'satellite', { mapRef, tileUrlRef, tileLayerRef, glLayerRef }) const markersRef = useRef>(new Map()) const itemsRef = useRef([]) const highlightedRef = useRef(null) @@ -392,29 +441,6 @@ function JourneyMap( plannedRef.current = plannedEntryIds }) - /** - * Draws the base: the satellite imagery while the reader asked for it, - * otherwise the basemap, which is a vector style unless the user brought their - * own raster template. Whatever drew it before is taken off first. A vector - * style is a GL canvas of its own, so leaving it under the imagery would keep - * a WebGL context busy drawing what nobody can see. - */ - const drawBase = useCallback((map: L.Map, torn: () => boolean) => { - const draw = ++baseDrawRef.current - drawnSatelliteRef.current = satelliteRef.current - tileLayerRef.current?.remove() - tileLayerRef.current = null - detachBasemapLayer(glLayerRef.current) - glLayerRef.current = null - const url = tileUrlRef.current - if (!satelliteRef.current && isVectorStyle(url)) { - void attachVectorBasemap(map, url, glLayerRef, () => torn() || draw !== baseDrawRef.current) - return - } - const tiles = rasterBase(url, satelliteRef.current, !isVectorStyle(url) && isGcj02Basemap(url)) - tiles.addTo(map) - tileLayerRef.current = tiles - }, []) const syncEntryCard = useEntryCardSync({ onCardClickRef: onMarkerCardClickRef, mapRef, markersRef, itemsRef, highlightedRef }) @@ -643,15 +669,7 @@ function JourneyMap( if (satelliteRef.current) return if (isVectorStyle(tileUrl)) restyleBasemap(glLayerRef.current, tileUrl) else tileLayerRef.current?.setUrl(tileUrl) - }, [tileUrl]) - - // The reader's base layer switch (#2594). Nothing to do when the map effect - // has just drawn the base the switch asks for. - useEffect(() => { - const map = mapRef.current - if (!map || drawnSatelliteRef.current === satellite) return - drawBase(map, () => mapRef.current !== map) - }, [satellite, drawBase]) + }, [tileUrl, satelliteRef]) // Photo layer (#1614). Its own effect on purpose: photos arriving must not tear // down and rebuild the map the way the entry effect does. Redrawn after every diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index 42a86127c..b1a76f931 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -268,7 +268,10 @@ function JourneyMapGL( // Read when a rebuilt map loads, and switched in place by its own effect below. const satellite = baseLayer === 'satellite' const satelliteRef = useRef(satellite) - satelliteRef.current = satellite + // Before the map effects below, like the refs above. + useEffect(() => { + satelliteRef.current = satellite + }) const mapLangRef = useRef(mapLang) mapLangRef.current = mapLang diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts index 44c6d3148..89cd7a014 100644 --- a/client/src/components/PDF/TripPDF.test.ts +++ b/client/src/components/PDF/TripPDF.test.ts @@ -1429,19 +1429,30 @@ describe('downloadTripPDF — transport notes', () => { // ── Large text (#2817) ────────────────────────────────────────────────────── +/** The preview frame, which every export below opens. */ +function previewFrame(): HTMLIFrameElement { + const frame = getIframe() + if (!frame) throw new Error('no preview frame') + return frame +} + describe('downloadTripPDF — large text', () => { beforeEach(() => { localStorage.removeItem('trek_pdf_large_text') }) - const largeToggle = () => document.querySelector('#pdf-large-text-toggle') + const largeToggle = () => { + const toggle = document.querySelector('#pdf-large-text-toggle') + if (!toggle) throw new Error('no large text switch') + return toggle + } it('FE-PDF-LARGE-001: the document keeps its compact type by default and offers the switch off', async () => { await downloadTripPDF(richArgs) - const html = getIframe()!.srcdoc + const html = previewFrame().srcdoc expect(html).not.toContain('pdf-large-text"') - expect(largeToggle()!.getAttribute('aria-checked')).toBe('false') + expect(largeToggle().getAttribute('aria-checked')).toBe('false') // One scale and one set of greys, at today's values unless the class is on. expect(html).toContain('--pdf-text: 1;') expect(html).toContain('body.pdf-large-text {') @@ -1451,7 +1462,7 @@ describe('downloadTripPDF — large text', () => { it('FE-PDF-LARGE-002: the clock and the booking code of a transport scale with the rest', async () => { await downloadTripPDF(richArgs) - const html = getIframe()!.srcdoc + const html = previewFrame().srcdoc expect(html).toContain('') expect(html).toContain('
Code: ABC123
') @@ -1460,11 +1471,11 @@ describe('downloadTripPDF — large text', () => { it('FE-PDF-LARGE-003: turning it on enlarges the open preview at once and is remembered', async () => { await downloadTripPDF(richArgs) - largeToggle()!.click() + largeToggle().click() - expect(largeToggle()!.getAttribute('aria-checked')).toBe('true') + expect(largeToggle().getAttribute('aria-checked')).toBe('true') expect(localStorage.getItem('trek_pdf_large_text')).toBe('1') - const body = getIframe()!.contentDocument?.body + const body = previewFrame().contentDocument?.body if (body) expect(body.classList.contains('pdf-large-text')).toBe(true) }) @@ -1473,8 +1484,8 @@ describe('downloadTripPDF — large text', () => { localStorage.setItem('trek_pdf_page_break_per_day', '0') await downloadTripPDF(richArgs) - expect(getIframe()!.srcdoc).toContain('') - expect(largeToggle()!.getAttribute('aria-checked')).toBe('true') + expect(previewFrame().srcdoc).toContain('') + expect(largeToggle().getAttribute('aria-checked')).toBe('true') localStorage.removeItem('trek_pdf_page_break_per_day') }) @@ -1482,8 +1493,8 @@ describe('downloadTripPDF — large text', () => { vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked') }) await downloadTripPDF(richArgs) - expect(getIframe()!.srcdoc).not.toContain('pdf-large-text"') - expect(largeToggle()!.getAttribute('aria-checked')).toBe('false') + expect(previewFrame().srcdoc).not.toContain('pdf-large-text"') + expect(largeToggle().getAttribute('aria-checked')).toBe('false') }) }) @@ -1512,12 +1523,12 @@ describe('downloadTripPDF — photos at thumbnail size', () => { const withImage = (image_url: string) => ({ ...richArgs, - assignments: { '10': [{ ...assignmentForDay, place: { ...placeWithDetails, image_url } }] } as any, + assignments: { '10': [{ ...assignmentForDay, place: { ...placeWithDetails, image_url } }] } as unknown as typeof richArgs.assignments, }) it('FE-PDF-THUMB-001: an uploaded photo is baked in as a small JPEG instead of its full-size URL', async () => { await downloadTripPDF(withImage('/uploads/colosseum.jpg')) - const html = getIframe()!.srcdoc + const html = previewFrame().srcdoc expect(html).toContain(``) expect(html).not.toContain('colosseum.jpg') @@ -1526,7 +1537,7 @@ describe('downloadTripPDF — photos at thumbnail size', () => { it('FE-PDF-THUMB-002: a photo from another origin keeps its URL', async () => { await downloadTripPDF(withImage('https://images.example.com/colosseum.jpg')) - expect(getIframe()!.srcdoc).toContain('') + expect(previewFrame().srcdoc).toContain('') }) it('FE-PDF-THUMB-003: a closed export dialog stops the photos and opens nothing', async () => { diff --git a/client/src/components/Packing/usePackingCategorySharing.test.ts b/client/src/components/Packing/usePackingCategorySharing.test.ts index b78baddee..70ffba6c9 100644 --- a/client/src/components/Packing/usePackingCategorySharing.test.ts +++ b/client/src/components/Packing/usePackingCategorySharing.test.ts @@ -18,14 +18,18 @@ const theirs = buildPackingItem({ id: 3, name: 'Lamp', category: 'Camp', owner_i const socks = buildPackingItem({ id: 4, name: 'Socks', category: 'Clothes', owner_id: ME }); const diary = buildPackingItem({ id: 5, name: 'Diary', category: 'Camp', owner_id: ME, is_private: 1 }); -function setup(items: PackingItem[], setPackingItemSharing: Mock = vi.fn(() => Promise.resolve())) { +function setup( + items: PackingItem[], + setPackingItemSharing: Mock = vi.fn(() => Promise.resolve()), + view: 'common' | 'personal' = 'common' +) { const toast = { success: vi.fn() }; const t = vi.fn((key: string) => key); const hook = renderHook(() => usePackingCategorySharing({ tripId: 7, items, - view: 'common', + view, defaultCategory: 'Other', currentUserId: ME, setPackingItemSharing, @@ -99,7 +103,8 @@ describe('usePackingCategorySharing', () => { is_private: 1, recipients: recipients(2, 3), }); - const { result, setPackingItemSharing, toast } = setup([kettle, pans]); + // Shared items are private ones with people added, so they live in the personal view. + const { result, setPackingItemSharing, toast } = setup([kettle, pans], undefined, 'personal'); act(() => result.current.openFor('Food')); expect(result.current.visibility).toBe('shared'); expect(result.current.recipientIds).toEqual([2, 3]); diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 7d49f4aa9..e341bffc7 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -252,14 +252,10 @@ export default function PlaceInspector({ const saveVersion = useSaveToCollectionStore(s => s.version) const [savedInCollection, setSavedInCollection] = useState(false) const [hoursExpanded, setHoursExpanded] = useState(false) - // Folded for the place on show only: any other place selected, coming back - // included, opens in full. - const [collapsed, setCollapsed] = useState(false) - const [foldedFor, setFoldedFor] = useState(place.id) - if (foldedFor !== place.id) { - setFoldedFor(place.id) - setCollapsed(false) - } + // Folded for the place on show only; any other selected, coming back included, opens in full. + const [foldedId, setFoldedId] = useState(null) + if (foldedId !== null && foldedId !== place?.id) setFoldedId(null) + const collapsed = foldedId === place?.id const [editingName, setEditingName] = useState(false) const [nameValue, setNameValue] = useState('') const nameInputRef = useRef(null) @@ -389,7 +385,7 @@ export default function PlaceInspector({ editingName={editingName} nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue} commitNameEdit={commitNameEdit} handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit} photoCredit={} onUpdatePlace={onUpdatePlace} collapsed={collapsed} - onToggleCollapsed={collapsible ? () => setCollapsed(c => !c) : undefined} + onToggleCollapsed={collapsible ? () => setFoldedId(collapsed ? null : place.id) : undefined} onClose={onClose} /> {/* The body scrolls inside the capped card; each block keeps its natural height (#1195). */} @@ -481,8 +477,7 @@ export default function PlaceInspector({ {/* Footer: the day action and the ways out on the left, the "…" and edit on the right. */} - {/* Measured as a container: squeezed between the sidebars (a tablet) the - worded buttons drop their words before the row would wrap. */} + {/* A container: squeezed between the sidebars, the buttons drop their words before wrapping. */}