feat(planner): a calmer place inspector footer, and a folded place opens in full again

The footer wrapped once a place had a day, a website and a collection. Now
only adding to the day, navigation and edit carry words, website and
collection are icons, and removing from the day and delete sit behind one
"…". The words drop out when the panel is narrow (a tablet) instead of the
row wrapping. Folding is kept for the place on show only (#3039).
This commit is contained in:
Maurice
2026-11-13 10:48:59 +01:00
parent e958a9a2bb
commit 43a8984e4c
2 changed files with 84 additions and 61 deletions
@@ -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(<PlaceInspector {...defaultProps} onDelete={onDelete} />);
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(<PlaceInspector {...defaultProps} place={p} selectedDayId={1} assignments={{ '1': [] }} />);
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(<PlaceInspector {...defaultProps} />);
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(<PlaceInspector {...defaultProps} />);
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(<PlaceInspector {...defaultProps} />);
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(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
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(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
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(<PlaceInspector {...defaultProps} selectedDayId={1} assignments={{ '1': [] }} />);
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(<PlaceInspector {...defaultProps} collapsible />);
expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute('aria-expanded', 'true');
rerender(<PlaceInspector {...defaultProps} place={louvre} />);
expect(screen.queryByRole('button', { name: 'Collapse' })).toBeNull();
});
@@ -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<number | null>(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<HTMLInputElement>(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 (
<div ref={cardRef}
@@ -384,7 +389,7 @@ export default function PlaceInspector({
editingName={editingName} nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue}
commitNameEdit={commitNameEdit} handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit}
photoCredit={<PhotoCredit imageUrl={place.image_url} />} 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({
)}
</div>
{/* Footer: the day action and the ways out on the left, delete and edit on the right. */}
<footer className={`${collapsed ? 'hidden' : 'flex'} flex-none flex-wrap items-center gap-2 border-t border-edge-faint px-4 py-2.5`}>
{/* 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. */}
<footer className={`${collapsed ? 'hidden' : 'flex'} flex-none flex-wrap items-center gap-2 border-t border-edge-faint px-4 py-2.5 [container-type:inline-size]`}>
{/* Collection mode — copy to trip + per-place status */}
{mode === 'collection' && onCopyToTrip && (
<DialogButton variant="primary" onClick={onCopyToTrip} icon={<Copy size={14} strokeWidth={2} />} aria-label={t('collections.copyToTrip')}>
@@ -486,23 +493,18 @@ export default function PlaceInspector({
{mode === 'collection' && collectionStatus && onSetStatus && (
<StatusBadge status={collectionStatus} onChange={onSetStatus} t={t} />
)}
{/* Trip mode — day assignment.
A stop a lodging booking put there is not offered either way. Taking it off
the day would leave the booking behind with nothing on the drive and no way
back short of saving it again, and putting a second one beside it is the
duplicate the stop exists to prevent. The booking is removed where it is
made: in the day's overnight block, or by turning the night back into a
pause in road trip mode. */}
{mode === 'trip' && !!selectedDayId && !bookedNight && (
assignmentInDay ? (
<DialogButton onClick={() => onRemoveAssignment?.(selectedDayId, assignmentInDay.id)} icon={<Minus size={14} strokeWidth={2} />} aria-label={t('inspector.removeFromDay')}>
<span className="max-sm:hidden">{t('inspector.removeFromDay')}</span>
</DialogButton>
) : (
<DialogButton variant="primary" onClick={() => onAssignToDay?.(place.id)} icon={<Plus size={14} strokeWidth={2} />}>
{t('inspector.addToDay')}
</DialogButton>
)
{/* Trip mode: adding to the day is a worded action, taking the place off
it waits in the "…" below. A stop a lodging booking put there is not
offered either way. Taking it off the day would leave the booking
behind with nothing on the drive and no way back short of saving it
again, and putting a second one beside it is the duplicate the stop
exists to prevent. The booking is removed where it is made: in the
day's overnight block, or by turning the night back into a pause in
road trip mode. */}
{mode === 'trip' && !!selectedDayId && !bookedNight && !assignmentInDay && (
<DialogButton onClick={() => onAssignToDay?.(place.id)} icon={<Plus size={14} strokeWidth={2} />} aria-label={t('inspector.addToDay')}>
<span className="[@container(max-width:600px)]:hidden">{t('inspector.addToDay')}</span>
</DialogButton>
)}
{navigationTargets.length > 0 && (
<>
@@ -519,7 +521,7 @@ export default function PlaceInspector({
aria-expanded={navigationTargets.length > 1 ? navOpen : undefined}
icon={<Navigation size={14} strokeWidth={2} />}
>
<span className="max-sm:hidden">{navigationLabel}</span>
<span className="[@container(max-width:460px)]:hidden">{navigationLabel}</span>
</DialogButton>
</span>
{navOpen && (
@@ -531,26 +533,34 @@ export default function PlaceInspector({
)}
</>
)}
{/* The ways out besides navigation: icons, named by their tooltips. */}
{websiteUrl && (
<DialogButton onClick={() => window.open(websiteUrl, '_blank', 'noopener,noreferrer')} icon={<ExternalLink size={14} strokeWidth={2} />} aria-label={t('inspector.website')}>
<span className="max-sm:hidden">{t('inspector.website')}</span>
</DialogButton>
<DialogIconButton label={t('inspector.website')} icon={<ExternalLink size={15} strokeWidth={2} />}
onClick={() => window.open(websiteUrl, '_blank', 'noopener,noreferrer')} />
)}
{/* Save to Collection — trip mode, independent of the Google Maps link */}
{showSaveToCollection && (
<DialogButton onClick={handleSaveToCollection}
icon={savedInCollection ? <BookmarkCheck size={14} strokeWidth={2} /> : <Bookmark size={14} strokeWidth={2} />}
aria-label={savedInCollection ? t('inspector.savedToCollection') : t('inspector.saveToCollection')}>
<span className="max-sm:hidden">{savedInCollection ? t('inspector.savedToCollection') : t('inspector.saveToCollection')}</span>
</DialogButton>
<DialogIconButton label={savedInCollection ? t('inspector.savedToCollection') : t('inspector.saveToCollection')}
icon={savedInCollection ? <BookmarkCheck size={15} strokeWidth={2} /> : <Bookmark size={15} strokeWidth={2} />}
onClick={handleSaveToCollection} />
)}
<FooterSpacer />
{mode === 'collection'
? (onRemoveFromList && <DeleteButton onClick={onRemoveFromList} label={t('collections.removeFromList')} />)
: (onDelete && <DeleteButton onClick={onDelete} />)}
: (
// The rare and the destructive behind one "…", so the row holds what is used.
<MoreButton label={t('files.menu')} size={36} alwaysVisible
className="!rounded-[10px] bg-surface-card !text-content shadow-sm ring-1 ring-edge-faint hover:!bg-surface-secondary"
items={[
mode === 'trip' && !!selectedDayId && !bookedNight && assignmentInDay && {
label: t('inspector.removeFromDay'), icon: Minus,
onClick: () => onRemoveAssignment?.(selectedDayId, assignmentInDay.id),
},
onDelete && { label: t('common.delete'), icon: Trash2, danger: true, onClick: onDelete },
]} />
)}
{mode === 'trip' && onEdit && (
<DialogButton variant="primary" onClick={onEdit} icon={<Pencil size={14} strokeWidth={2} />} aria-label={t('common.edit')}>
<span className="max-sm:hidden">{t('common.edit')}</span>
<span className="[@container(max-width:460px)]:hidden">{t('common.edit')}</span>
</DialogButton>
)}
</footer>