diff --git a/client/src/components/Planner/PlaceDetailsColumn.test.tsx b/client/src/components/Planner/PlaceDetailsColumn.test.tsx new file mode 100644 index 000000000..078c4c353 --- /dev/null +++ b/client/src/components/Planner/PlaceDetailsColumn.test.tsx @@ -0,0 +1,225 @@ +/** + * FE-PDC-001..015 — the detail column beside the add-place search field. + * + * mapsApi.placeEnrichment is mocked; these cover the load states, the caches, + * the abort on selection change, image picking and the attribution rendering + * that the Commons licences require. + */ +import React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import type { MapsPlaceEnrichmentResult } from '@trek/shared' +import PlaceDetailsColumn, { __clearEnrichmentCacheForTests, type PlaceDetailsSelection } from './PlaceDetailsColumn' + +const placeEnrichment = vi.fn() +vi.mock('../../api/client', () => ({ mapsApi: { placeEnrichment: (...a: unknown[]) => placeEnrichment(...a) } })) + +/** Echo the key back, which keeps the assertions readable. */ +const t = ((key: string) => key) as never + +const SELECTION: PlaceDetailsSelection = { placeId: 'way:1', lat: 50.9, lng: 6.96, name: 'Museum Ludwig' } + +const COMMONS_PHOTO = { + key: 'way:1~p0', + url: '/api/maps/place-photo/way%3A1~p0/bytes', + attribution: 'Alice', + license: 'CC BY-SA 4.0', + licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/', + sourceUrl: 'https://commons.wikimedia.org/wiki/File:X.jpg', + source: 'wikimedia' as const, +} + +const GOOGLE_PHOTO = { + key: 'way:1~p1', + url: '/api/maps/place-photo/way%3A1~p1/bytes', + attribution: null, + license: null, + licenseUrl: null, + sourceUrl: null, + source: 'google' as const, +} + +const RESULT: MapsPlaceEnrichmentResult = { + photos: [COMMONS_PHOTO], + description: { + text: 'Ein Museum in Köln.', + source: 'wikipedia', + sourceUrl: 'https://de.wikipedia.org/wiki/Museum_Ludwig', + license: 'CC BY-SA 4.0', + }, +} + +function renderColumn(props: Partial> = {}) { + const onPickImage = vi.fn() + const onAdoptDescription = vi.fn() + const utils = render( + , + ) + return { ...utils, onPickImage, onAdoptDescription } +} + +beforeEach(() => { + placeEnrichment.mockReset() + placeEnrichment.mockResolvedValue(RESULT) + sessionStorage.clear() + __clearEnrichmentCacheForTests() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('PlaceDetailsColumn', () => { + it('FE-PDC-001: shows the empty state and calls nothing while nothing is selected', () => { + renderColumn({ selection: null }) + + expect(screen.getByText('places.details.empty')).toBeInTheDocument() + expect(placeEnrichment).not.toHaveBeenCalled() + }) + + it('FE-PDC-002: shows a loading state, then the result', async () => { + let settle: (v: MapsPlaceEnrichmentResult) => void = () => {} + placeEnrichment.mockReturnValue(new Promise((r) => { settle = r })) + + renderColumn() + expect(screen.getByText('places.details.loading')).toBeInTheDocument() + + settle(RESULT) + await waitFor(() => expect(screen.getByText('Ein Museum in Köln.')).toBeInTheDocument()) + }) + + it('FE-PDC-003: sends the selection and the language to the endpoint', async () => { + renderColumn() + + await waitFor(() => expect(placeEnrichment).toHaveBeenCalled()) + expect(placeEnrichment.mock.calls[0][0]).toEqual({ + placeId: 'way:1', + lat: 50.9, + lng: 6.96, + name: 'Museum Ludwig', + lang: 'de', + }) + }) + + it('FE-PDC-004: renders author and licence under every Commons picture', async () => { + renderColumn() + + const credit = await screen.findByText('Alice') + // Links to the file description page, where the full terms are. + expect(credit.closest('a')).toHaveAttribute('href', 'https://commons.wikimedia.org/wiki/File:X.jpg') + const licence = screen.getByText('CC BY-SA 4.0', { selector: 'a' }) + expect(licence).toHaveAttribute('href', 'https://creativecommons.org/licenses/by-sa/4.0/') + }) + + it('FE-PDC-005: names the source when a picture has no author, instead of inventing one', async () => { + placeEnrichment.mockResolvedValue({ photos: [GOOGLE_PHOTO], description: null }) + renderColumn() + + expect(await screen.findByText('Google')).toBeInTheDocument() + }) + + it('FE-PDC-006: picking a picture reports its proxy URL', async () => { + const { onPickImage } = renderColumn() + + fireEvent.click(await screen.findByRole('button', { name: /places.details.pickImage/ })) + + expect(onPickImage).toHaveBeenCalledWith('/api/maps/place-photo/way%3A1~p0/bytes') + }) + + it('FE-PDC-007: clicking the picked picture again clears the hero image', async () => { + const { onPickImage } = renderColumn({ selectedImageUrl: COMMONS_PHOTO.url }) + + const tile = await screen.findByRole('button', { name: /places.details.pickImage/ }) + expect(tile).toHaveAttribute('aria-pressed', 'true') + fireEvent.click(tile) + + expect(onPickImage).toHaveBeenCalledWith(null) + }) + + it('FE-PDC-008: adopting the description hands the text up', async () => { + const { onAdoptDescription } = renderColumn() + + fireEvent.click(await screen.findByRole('button', { name: 'places.details.adopt' })) + + expect(onAdoptDescription).toHaveBeenCalledWith('Ein Museum in Köln.') + }) + + it('FE-PDC-009: will not overwrite a description the user already wrote', async () => { + renderColumn({ hasDescription: true }) + + expect(await screen.findByRole('button', { name: 'places.details.adopt' })).toBeDisabled() + }) + + it('FE-PDC-010: shows where the description came from', async () => { + renderColumn() + + const link = await screen.findByRole('link', { name: /Wikipedia/ }) + expect(link).toHaveAttribute('href', 'https://de.wikipedia.org/wiki/Museum_Ludwig') + // The photo credit carries the same licence string, so scope to the source + // line that sits directly under the description. + expect(link.parentElement).toHaveTextContent('CC BY-SA 4.0') + }) + + it('FE-PDC-011: says so when the admin switched enrichment off', async () => { + placeEnrichment.mockResolvedValue({ photos: [], description: null, disabled: true }) + renderColumn() + + expect(await screen.findByText('places.details.disabled')).toBeInTheDocument() + }) + + it('FE-PDC-012: says so when a place has no pictures', async () => { + placeEnrichment.mockResolvedValue({ photos: [], description: null }) + renderColumn() + + expect(await screen.findByText('places.details.noPhotos')).toBeInTheDocument() + }) + + it('FE-PDC-013: shows an error state when the request fails', async () => { + placeEnrichment.mockRejectedValue(new Error('network')) + renderColumn() + + expect(await screen.findByText('places.details.error')).toBeInTheDocument() + }) + + it('FE-PDC-014: serves a second look at the same place from cache', async () => { + const first = renderColumn() + await screen.findByText('Ein Museum in Köln.') + first.unmount() + + renderColumn() + await screen.findByText('Ein Museum in Köln.') + + expect(placeEnrichment).toHaveBeenCalledTimes(1) + }) + + it('FE-PDC-015: aborts the pending request when the selection changes', async () => { + placeEnrichment.mockReturnValue(new Promise(() => {})) // never settles + const { rerender, onPickImage, onAdoptDescription } = renderColumn() + + await waitFor(() => expect(placeEnrichment).toHaveBeenCalledTimes(1)) + const firstSignal = placeEnrichment.mock.calls[0][1] as AbortSignal + expect(firstSignal.aborted).toBe(false) + + rerender( + , + ) + + await waitFor(() => expect(firstSignal.aborted).toBe(true)) + expect(placeEnrichment).toHaveBeenCalledTimes(2) + }) +}) diff --git a/client/src/components/Planner/PlaceDetailsColumn.tsx b/client/src/components/Planner/PlaceDetailsColumn.tsx new file mode 100644 index 000000000..3e0bbc3bb --- /dev/null +++ b/client/src/components/Planner/PlaceDetailsColumn.tsx @@ -0,0 +1,310 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Check, ImageOff, Loader2, ExternalLink } from 'lucide-react' +import type { MapsPlaceEnrichmentResult, PlacePhotoCandidate } from '@trek/shared' +import { mapsApi } from '../../api/client' +import type { TranslationFn } from '../../types' + +/** The place the column is describing. Null while nothing is selected. */ +export interface PlaceDetailsSelection { + placeId?: string + lat: number + lng: number + name: string +} + +interface PlaceDetailsColumnProps { + selection: PlaceDetailsSelection | null + /** Currently chosen hero image, so the picked tile can show as picked. */ + selectedImageUrl?: string + onPickImage: (url: string | null) => void + onAdoptDescription: (text: string) => void + /** True once the form's description field has something in it. */ + hasDescription: boolean + language: string + t: TranslationFn +} + +/** + * Module-level cache plus sessionStorage, same shape as usePlaceDetails in + * PlaceInspector. Clicking back and forth between two search results must not + * pay for the provider fan-out twice. + */ +const enrichmentCache = new Map() + +/** Test seam: the module-level cache otherwise leaks between cases. */ +export function __clearEnrichmentCacheForTests(): void { + enrichmentCache.clear() +} + +function readSession(key: string): MapsPlaceEnrichmentResult | undefined { + try { + const raw = sessionStorage.getItem(key) + return raw ? (JSON.parse(raw) as MapsPlaceEnrichmentResult) : undefined + } catch { + return undefined + } +} + +function writeSession(key: string, value: MapsPlaceEnrichmentResult): void { + try { + sessionStorage.setItem(key, JSON.stringify(value)) + } catch { + /* private mode / quota — the in-memory cache still helps for this session */ + } +} + +function cacheKeyFor(selection: PlaceDetailsSelection, language: string): string { + const id = selection.placeId || `coords:${selection.lat}:${selection.lng}` + return `enrich_${id}_${language}` +} + +type LoadState = 'idle' | 'loading' | 'ready' | 'error' + +export default function PlaceDetailsColumn({ + selection, + selectedImageUrl, + onPickImage, + onAdoptDescription, + hasDescription, + language, + t, +}: PlaceDetailsColumnProps): React.ReactElement { + const [data, setData] = useState(null) + const [state, setState] = useState('idle') + const abortRef = useRef(null) + + const selectionKey = selection ? cacheKeyFor(selection, language) : null + + useEffect(() => { + // Abort whatever the previous selection started; its answer is no longer + // about the place on screen. + abortRef.current?.abort() + abortRef.current = null + + if (!selection || !selectionKey) { + setData(null) + setState('idle') + return + } + + const cached = enrichmentCache.get(selectionKey) ?? readSession(selectionKey) + if (cached) { + enrichmentCache.set(selectionKey, cached) + setData(cached) + setState('ready') + return + } + + const controller = new AbortController() + abortRef.current = controller + setState('loading') + setData(null) + + mapsApi + .placeEnrichment( + { placeId: selection.placeId, lat: selection.lat, lng: selection.lng, name: selection.name, lang: language }, + controller.signal, + ) + .then((result) => { + if (controller.signal.aborted) return + enrichmentCache.set(selectionKey, result) + writeSession(selectionKey, result) + setData(result) + setState('ready') + }) + .catch((err: unknown) => { + if (controller.signal.aborted || (err as { code?: string })?.code === 'ERR_CANCELED') return + setState('error') + }) + + return () => controller.abort() + // selectionKey folds in the place id (or its coordinates) and the language, + // which is everything the answer depends on. Depending on `selection` itself + // would refetch whenever the object identity changes without the place + // having changed. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectionKey, language]) + + useEffect(() => () => abortRef.current?.abort(), []) + + return ( + + ) +} + +function PhotoStrip({ + photos, + selectedImageUrl, + onPickImage, + t, +}: { + photos: PlacePhotoCandidate[] + selectedImageUrl?: string + onPickImage: (url: string | null) => void + t: TranslationFn +}): React.ReactElement { + if (photos.length === 0) { + return ( +
+ + {t('places.details.noPhotos')} +
+ ) + } + + return ( +
+

{t('places.details.pickImage')}

+
+ {photos.map((photo) => { + const picked = selectedImageUrl === photo.url + return ( +
+ + +
+ ) + })} +
+
+ ) +} + +/** + * Author and licence under every thumbnail. + * + * Not decoration: Commons images are largely CC BY / CC BY-SA, and reusing one + * without naming its author and licence does not satisfy those terms. When a + * source hands us no author (Google), we say where it came from rather than + * inventing a credit. + */ +function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElement { + const sourceLabel = photo.source === 'google' ? 'Google' : photo.source === 'wikipedia' ? 'Wikipedia' : 'Wikimedia Commons' + const credit = photo.attribution || sourceLabel + + return ( +

+ {photo.sourceUrl ? ( + + {credit} + + ) : ( + credit + )} + {photo.license && ( + <> + {' · '} + {photo.licenseUrl ? ( + + {photo.license} + + ) : ( + photo.license + )} + + )} +

+ ) +} + +function DescriptionBlock({ + description, + hasDescription, + onAdoptDescription, + t, +}: { + description: MapsPlaceEnrichmentResult['description'] + hasDescription: boolean + onAdoptDescription: (text: string) => void + t: TranslationFn +}): React.ReactElement | null { + if (!description) return null + + const sourceLabel = + description.source === 'google' ? 'Google' : description.source === 'osm' ? 'OpenStreetMap' : 'Wikipedia' + + return ( +
+

{t('places.details.description')}

+

{description.text}

+

+ {description.sourceUrl ? ( + + {sourceLabel} + + + ) : ( + sourceLabel + )} + {description.license && ` · ${description.license}`} +

+ +
+ ) +} diff --git a/client/src/components/Planner/PlaceFormModal.helpers.ts b/client/src/components/Planner/PlaceFormModal.helpers.ts index 3a6b1b49a..fea2de328 100644 --- a/client/src/components/Planner/PlaceFormModal.helpers.ts +++ b/client/src/components/Planner/PlaceFormModal.helpers.ts @@ -15,6 +15,10 @@ export interface PlaceFormData { google_place_id?: string google_ftid?: string osm_id?: string + // Hero image picked from the detail column. Optional and absent from + // DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets + // it, and places.service already writes image_url through on create/update. + image_url?: string } export function isGoogleMapsUrl(input: string): boolean { diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 4b1d26eed..02091e8c9 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -7,6 +7,7 @@ import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { useAddonStore } from '../../store/addonStore' import CollectionPicker from '../Collections/CollectionPicker' +import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn' import { useToast } from '../shared/Toast' import { Search, Paperclip, X, AlertTriangle, Loader2, Plus } from 'lucide-react' import { useTranslation } from '../../i18n' @@ -84,6 +85,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const [showNewCategory, setShowNewCategory] = useState(false) const [isSaving, setIsSaving] = useState(false) const [duplicateWarning, setDuplicateWarning] = useState(null) + // What the detail column is describing. Null until the user picks a result. + const [detailsSelection, setDetailsSelection] = useState(null) const [pendingFiles, setPendingFiles] = useState([]) const fileRef = useRef(null) const searchInputRef = useRef(null) @@ -93,7 +96,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const acAbortRef = useRef(null) const toast = useToast() const { t, language } = useTranslation() - const { hasMapsKey } = useAuthStore() + const { hasMapsKey, placesEnrichEnabled } = useAuthStore() const can = useCanDo() const tripObj = useTripStore((s) => s.trip) const canUploadFiles = can('file_upload', tripObj) @@ -267,6 +270,19 @@ function usePlaceFormModal(props: PlaceFormModalProps) { website: result.website || prev.website, phone: result.phone || prev.phone, })) + // The one point every pick flows through, so the detail column hangs here. + // A new pick drops whatever hero image belonged to the previous place. + const lat = Number(result.lat) + const lng = Number(result.lng) + if (Number.isFinite(lat) && Number.isFinite(lng)) { + setDetailsSelection({ + placeId: result.google_place_id || result.osm_id || undefined, + lat, + lng, + name: result.name || '', + }) + setForm(prev => ({ ...prev, image_url: undefined })) + } setMapsResults([]) setMapsSearch('') } @@ -453,6 +469,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { t, language, hasMapsKey, + placesEnrichEnabled, can, tripObj, canUploadFiles, @@ -472,6 +489,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { hasTimeError, handleSubmit, duplicateWarning, + detailsSelection, } } @@ -517,6 +535,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { t, language, hasMapsKey, + placesEnrichEnabled, can, tripObj, canUploadFiles, @@ -536,16 +555,22 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { hasTimeError, handleSubmit, duplicateWarning, + detailsSelection, } = S - // Desktop + Collections addon → two columns (form + saved-place picker). Mobile + // Desktop + Collections addon → the saved-place picker on the right. Mobile // always keeps the original single-column form untouched. const twoColumn = !isMobile && collectionsEnabled + // The detail column sits on the left on desktop whenever enrichment is on. It + // stays mounted with the selection null rather than appearing on the first + // pick — otherwise the dialog would jump sideways mid-typing. + const showDetails = !isMobile && placesEnrichEnabled + const modalSize = isMobile ? 'lg' : showDetails && twoColumn ? '5xl' : showDetails || twoColumn ? '4xl' : 'lg' return (