From 46318db5ac7ae3ba6249bb1e3eea3f12a7627a3b Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 10 Aug 2026 11:45:25 +0200 Subject: [PATCH] Give the detail column a lead image and pin its one action --- .../Planner/PlaceDetailsColumn.test.tsx | 105 +++++++++++ .../components/Planner/PlaceDetailsColumn.tsx | 169 ++++++++++++------ .../screens/trip/sheets/MPlaceEditSheet.tsx | 39 ++-- 3 files changed, 243 insertions(+), 70 deletions(-) diff --git a/client/src/components/Planner/PlaceDetailsColumn.test.tsx b/client/src/components/Planner/PlaceDetailsColumn.test.tsx index 917b786d0..b0d576b7b 100644 --- a/client/src/components/Planner/PlaceDetailsColumn.test.tsx +++ b/client/src/components/Planner/PlaceDetailsColumn.test.tsx @@ -478,3 +478,108 @@ describe('PlaceDetailsColumn — chain description', () => { expect(screen.queryByText('places.details.aboutBrandNote')).not.toBeInTheDocument() }) }) + +/** + * FE-PDC-032..037 — the lead image and its rail. + * + * The grid was three 84px squares in a 288px column, which is too small to tell + * a facade from a foyer — the one thing the picker exists for. What must NOT + * change: picking a picture still takes one click. + */ +describe('PlaceDetailsColumn — picture layout', () => { + const photo = (n: number, over: Record = {}) => ({ + key: `way:1~p${n}`, + url: `/api/maps/place-photo/p${n}/bytes`, + attribution: `Author ${n}`, + license: 'CC BY-SA 4.0', + licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/', + sourceUrl: `https://commons.wikimedia.org/wiki/File:P${n}.jpg`, + source: 'wikimedia' as const, + ...over, + }) + + const withPhotos = (n: number) => + placeEnrichment.mockResolvedValue({ + photos: Array.from({ length: n }, (_, i) => photo(i)), + facts: [], + description: null, + }) + + // alt="" makes an image presentational, so it carries no `img` role — these + // read the DOM directly, the way the strip cases above already do. + const shownImages = () => Array.from(document.querySelectorAll('img')) + const leadSrc = () => shownImages()[0]?.getAttribute('src') + + it('FE-PDC-032: shows the first picture large and the rest as a rail', async () => { + withPhotos(4) + renderColumn() + + await screen.findByText('places.details.pickImage') + // One lead plus one thumbnail per picture. + expect(shownImages()).toHaveLength(5) + expect(leadSrc()).toBe('/api/maps/place-photo/p0/bytes') + }) + + it('FE-PDC-033: leaves the rail off when there is only one picture', async () => { + withPhotos(1) + renderColumn() + + await screen.findByText('places.details.pickImage') + expect(shownImages()).toHaveLength(1) + }) + + it('FE-PDC-034: picking a picture is still one click', async () => { + withPhotos(3) + const { onPickImage } = renderColumn() + + await screen.findByText('places.details.pickImage') + // Buttons: lead first, then one per thumbnail. + const buttons = screen.getAllByRole('button', { name: /places.details.pickImage/ }) + fireEvent.click(buttons[2]) + + expect(onPickImage).toHaveBeenCalledWith('/api/maps/place-photo/p1/bytes') + expect(onPickImage).toHaveBeenCalledTimes(1) + }) + + it('FE-PDC-035: hovering a thumbnail previews it without picking it', async () => { + withPhotos(3) + const { onPickImage } = renderColumn() + + await screen.findByText('places.details.pickImage') + const buttons = screen.getAllByRole('button', { name: /places.details.pickImage/ }) + fireEvent.mouseEnter(buttons[3]) + + expect(leadSrc()).toBe('/api/maps/place-photo/p2/bytes') + expect(onPickImage).not.toHaveBeenCalled() + }) + + it('FE-PDC-036: clicking the lead picks the picture it is showing, and unpicks it again', async () => { + withPhotos(2) + const { onPickImage } = renderColumn({ selectedImageUrl: '/api/maps/place-photo/p1/bytes' }) + + await screen.findByText('places.details.pickImage') + const lead = screen.getAllByRole('button', { name: /places.details.pickImage/ })[0] + // The picked one is what the lead slot shows. + expect(leadSrc()).toBe('/api/maps/place-photo/p1/bytes') + + fireEvent.click(lead) + expect(onPickImage).toHaveBeenCalledWith(null) + }) + + it('FE-PDC-037: credits the picture in the lead slot, licence included', async () => { + withPhotos(2) + renderColumn() + + // The credit rides on the picture rather than sitting in its own line, which + // used to change text on every hover. + expect(await screen.findByText('Author 0')).toBeInTheDocument() + expect(screen.getByText('Author 0').closest('a')).toHaveAttribute( + 'href', + 'https://commons.wikimedia.org/wiki/File:P0.jpg', + ) + expect(screen.getAllByText('CC BY-SA 4.0')[0].closest('a')).toHaveAttribute( + 'href', + 'https://creativecommons.org/licenses/by-sa/4.0/', + ) + }) +}) diff --git a/client/src/components/Planner/PlaceDetailsColumn.tsx b/client/src/components/Planner/PlaceDetailsColumn.tsx index 4ed33ab77..90345e4b1 100644 --- a/client/src/components/Planner/PlaceDetailsColumn.tsx +++ b/client/src/components/Planner/PlaceDetailsColumn.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from 'react' import { Accessibility, + ArrowRight, Bike, Building2, Check, @@ -195,13 +196,17 @@ export default function PlaceDetailsColumn({ !data?.rating return ( -