diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index 31252640b..42a31b527 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -108,6 +108,14 @@ beforeEach(() => { // ── Tests ───────────────────────────────────────────────────────────────────── + +/** Opens the footer's "…" and returns the menu with the rare and destructive actions. */ +async function openMoreMenu() { + const footer = document.querySelector('footer') as HTMLElement; + fireEvent.click(within(footer).getByRole('button', { name: 'More options' })); + return await screen.findByRole('menu'); +} + describe('PlaceInspector', () => { // ── Rendering ────────────────────────────────────────────────────────────── @@ -211,11 +219,9 @@ describe('PlaceInspector', () => { const user = userEvent.setup(); const onDelete = vi.fn(); render(); - const allButtons = screen.getAllByRole('button'); - // The square delete button sits just before Edit, named by its tooltip. - const deleteBtn = screen.getByRole('button', { name: 'Delete' }); - expect(allButtons[allButtons.length - 2]).toBe(deleteBtn); - await user.click(deleteBtn); + // Delete waits in the footer's "…", beside Edit. + const menu = await openMoreMenu(); + await user.click(within(menu).getByRole('button', { name: 'Delete' })); expect(onDelete).toHaveBeenCalled(); }); @@ -270,9 +276,9 @@ describe('PlaceInspector', () => { onRemoveAssignment={onRemoveAssignment} /> ); - // Find the remove button — it carries the "Remove from Day" label - const removeBtn = screen.getByText('Remove from Day').closest('button')!; - await user.click(removeBtn); + // Taking it off the day waits in the footer's "…". + const menu = await openMoreMenu(); + await user.click(within(menu).getByRole('button', { name: 'Remove from Day' })); // Component calls onRemoveAssignment(selectedDayId, assignmentInDay.id) expect(onRemoveAssignment).toHaveBeenCalledWith(1, 99); }); @@ -1205,18 +1211,20 @@ describe('PlaceInspector', () => { await user.click(screen.getAllByRole('button').find(b => b.textContent?.includes('Navigation'))!); await user.click(await screen.findByRole('menuitem', { name: 'OpenStreetMap' })); - fireEvent.click(screen.getByText('Open Website').closest('button')!); + fireEvent.click(screen.getByRole('button', { name: 'Open Website' })); expect(open).toHaveBeenCalledTimes(2); expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank', 'noopener,noreferrer']); vi.unstubAllGlobals(); }); - it('FE-PLANNER-INSPECTOR-070: the footer keeps the ways out on the left and delete + edit on the right', () => { + it('FE-PLANNER-INSPECTOR-070: the footer keeps the ways out on the left and the "…" + edit on the right', () => { const p = buildPlace({ id: 712, name: 'Footer Place', lat: 48.1, lng: 2.1, website: 'https://example.org' }); render(); const footer = document.querySelector('footer') as HTMLElement; const names = within(footer).getAllByRole('button').map(b => b.getAttribute('aria-label') || b.textContent); - expect(names).toEqual(['Add to Day', 'Navigation', 'Open Website', 'Delete', 'Edit']); + expect(names).toEqual(['Add to Day', 'Navigation', 'Open Website', 'More options', 'Edit']); + // Only the day action, navigation and edit carry words; the website is an icon named by its tooltip. + expect(within(footer).getByRole('button', { name: 'Open Website' })).toHaveTextContent(''); // Hover looks are classes on the tokens now, not inline styles swapped by handlers. const edit = within(footer).getByRole('button', { name: 'Edit' }); expect(edit.getAttribute('style') ?? '').not.toMatch(/background/); @@ -1349,7 +1357,7 @@ describe('PlaceInspector', () => { http.get('/api/addons/collections/membership', () => HttpResponse.json({ saved: true, collections: [] })), ); render(); - expect(await screen.findByText('Saved')).toBeTruthy(); + expect(await screen.findByRole('button', { name: 'Saved' })).toBeTruthy(); }); it('FE-PLANNER-INSPECTOR-082: clicking save hands the whole place to the collection picker', async () => { @@ -1358,7 +1366,7 @@ describe('PlaceInspector', () => { http.get('/api/addons/collections/membership', () => HttpResponse.json({ saved: false, collections: [] })), ); render(); - fireEvent.click((await screen.findByText('Save to Collection')).closest('button')!); + fireEvent.click(await screen.findByRole('button', { name: 'Save to Collection' })); expect(useSaveToCollectionStore.getState().target).toMatchObject({ name: 'Eiffel Tower', source_place_id: place.id, lat: 48.8584, lng: 2.2945, }); @@ -1370,7 +1378,7 @@ describe('PlaceInspector', () => { http.get('/api/addons/collections/membership', () => new HttpResponse(null, { status: 500 })), ); render(); - expect(await screen.findByText('Save to Collection')).toBeTruthy(); + expect(await screen.findByRole('button', { name: 'Save to Collection' })).toBeTruthy(); }); // ── Plugin contributions (#1429) ───────────────────────────────────────────── @@ -1647,7 +1655,7 @@ describe('PlaceInspector', () => { expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press'); }); - it('FE-PLANNER-INSPECTOR-102: a member without place_edit gets neither Edit nor Delete nor the inline rename (#2446)', () => { + it('FE-PLANNER-INSPECTOR-102: a member without place_edit gets neither Edit nor Delete nor the inline rename (#2446)', async () => { // buildUser() is a plain member and buildTrip({ id: 1 }) belongs to somebody else. usePermissionsStore.setState({ permissions: { place_edit: 'trip_owner' } }); const onEdit = vi.fn(); @@ -1655,7 +1663,8 @@ describe('PlaceInspector', () => { const onUpdatePlace = vi.fn(); const member = render(); expect(screen.queryByText('Edit')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument(); + // With nothing to offer, the "…" is not there either. + expect(screen.queryByRole('button', { name: 'More options' })).not.toBeInTheDocument(); fireEvent.doubleClick(screen.getByText(place.name)); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); member.unmount(); @@ -1663,11 +1672,11 @@ describe('PlaceInspector', () => { seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: useAuthStore.getState().user!.id }) }); render(); expect(screen.getByText('Edit')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + expect(within(await openMoreMenu()).getByRole('button', { name: 'Delete' })).toBeInTheDocument(); }); - it('FE-PLANNER-INSPECTOR-017b: a stop a booked night wrote is neither removed nor doubled here', () => { + it('FE-PLANNER-INSPECTOR-017b: a stop a booked night wrote is neither removed nor doubled here', async () => { // Taking it off the day would leave the booking behind with nothing on the drive and // no way back short of saving it again; offering to add it would put a second copy // of the same hotel beside it. The night is removed where it is made: the day's @@ -1683,7 +1692,7 @@ describe('PlaceInspector', () => { /> ); - expect(screen.queryByRole('button', { name: /remove from day/i })).toBeNull(); + expect(within(await openMoreMenu()).queryByRole('button', { name: /remove from day/i })).toBeNull(); expect(screen.queryByRole('button', { name: /add to day/i })).toBeNull(); }); @@ -1699,7 +1708,7 @@ describe('PlaceInspector', () => { expect(screen.queryByText('Remove from Day')).toBeNull(); }); - it('FE-PLANNER-INSPECTOR-017d: a stop the traveller placed at the booked hotel can still be removed', () => { + it('FE-PLANNER-INSPECTOR-017d: a stop the traveller placed at the booked hotel can still be removed', async () => { // A second copy that did get onto the day is the traveller's own row and shows // in the list. That one keeps its "Remove from day", which is the way back. const onRemoveAssignment = vi.fn(); @@ -1715,12 +1724,12 @@ describe('PlaceInspector', () => { /> ); - fireEvent.click(screen.getByText('Remove from Day').closest('button')!); + fireEvent.click(within(await openMoreMenu()).getByRole('button', { name: 'Remove from Day' })); expect(onRemoveAssignment).toHaveBeenCalledWith(1, 100); expect(screen.queryByText('Add to Day')).toBeNull(); }); - it('FE-PLANNER-INSPECTOR-017e: any booked stop at the place keeps the buttons off, whichever row the store lists first', () => { + it('FE-PLANNER-INSPECTOR-017e: any booked stop at the place keeps the buttons off, whichever row the store lists first', async () => { // With the service stops switched off the day list can hide a hotel the traveller // placed as well, so the store may list that row ahead of the booked one. The // question is whether a booking holds this place on this day, not which row comes first. @@ -1730,7 +1739,7 @@ describe('PlaceInspector', () => { render(); expect(screen.queryByText('Add to Day')).toBeNull(); - expect(screen.queryByText('Remove from Day')).toBeNull(); + expect(within(await openMoreMenu()).queryByRole('button', { name: 'Remove from Day' })).toBeNull(); }); // ── Head band (booking-detail language) ────────────────────────────────────── @@ -1867,6 +1876,10 @@ describe('PlaceInspector folded to its head (#3039)', () => { expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute('aria-expanded', 'true'); + // Back on the first place it is open in full too: the fold went with the selection. + rerender(); + expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute('aria-expanded', 'true'); + rerender(); expect(screen.queryByRole('button', { name: 'Collapse' })).toBeNull(); }); diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index ec8e340e6..7d49f4aa9 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -6,7 +6,7 @@ import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { markdownLinkComponents } from '../shared/markdownLink' -import { Clock, MapPin, ExternalLink, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Navigation, Mountain, Bookmark, BookmarkCheck, Copy } from 'lucide-react' +import { Clock, MapPin, ExternalLink, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Navigation, Mountain, Bookmark, BookmarkCheck, Copy, Trash2 } from 'lucide-react' import { hoursLines, periodsFromWeek } from './placeHours' import { BlurredCode } from '../shared/BookingCode' import PlaceRating from '../shared/StarRating' @@ -40,10 +40,10 @@ import { resolveOpenNow, resolvePlaceTimeZone, getWeekdayIndex, type OpeningPeri import { convertHoursLine } from './placeHoursFormat' import type { EndDayControlProps } from '../Roadtrip/EndDayControl' import VisitControls, { type RoadtripStayControl } from '../Roadtrip/VisitControls' -import { DialogButton, DialogSection, DeleteButton, FooterSpacer, fs } from '../shared/DialogShell' +import { DialogButton, DialogIconButton, DialogSection, DeleteButton, FooterSpacer, fs } from '../shared/DialogShell' import { BOX, Field, TypeTile, toneOf, toneTint, useOpenFile } from './bookings/bookingParts' import { parseMeta } from './bookings/bookingsModel' -import { SoftPill } from './planParts' +import { MoreButton, SoftPill } from './planParts' import { InspectorHead } from './PlaceInspectorHead' import { usePlaceLanguage } from '../../hooks/usePlaceLanguage' import { participantsWith, participantsWithout, splitParticipants, usePlaceFileUpload } from './usePlaceActions' @@ -252,8 +252,14 @@ export default function PlaceInspector({ const saveVersion = useSaveToCollectionStore(s => s.version) const [savedInCollection, setSavedInCollection] = useState(false) const [hoursExpanded, setHoursExpanded] = useState(false) - // Folded by place, so the next place selected opens in full. - const [collapsedPlaceId, setCollapsedPlaceId] = useState(null) + // 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) + } const [editingName, setEditingName] = useState(false) const [nameValue, setNameValue] = useState('') const nameInputRef = useRef(null) @@ -362,7 +368,6 @@ export default function PlaceInspector({ const description = place.description || googleDetails?.summary const showRating = (mode === 'trip' && !!onRate) || !!shortReview const mapCard = centredMapCard(leftWidth, rightWidth, { clearPill: clearMapPill }) - const collapsed = collapsedPlaceId === place.id return (
} onUpdatePlace={onUpdatePlace} collapsed={collapsed} - onToggleCollapsed={collapsible ? () => setCollapsedPlaceId(collapsed ? null : place.id) : undefined} + onToggleCollapsed={collapsible ? () => setCollapsed(c => !c) : undefined} onClose={onClose} /> {/* The body scrolls inside the capped card; each block keeps its natural height (#1195). */} @@ -475,8 +480,10 @@ export default function PlaceInspector({ )}
- {/* Footer: the day action and the ways out on the left, delete and edit on the right. */} -