mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
d1e3d8a75a
* Offer Waze and Apple Maps beside Google Maps A place could only be opened in Google Maps, and the request in #919 was for the apps people actually drive with. Google keeps the precise link it always had: ftid, then place id, then the details URL, and coordinates only as a last resort, so it still lands on the entry inside a mall rather than on the roof. Waze and Apple Maps take coordinates and nothing else, which is why they are absent for a place with no position, and why they open with navigation armed instead of on a place view. No setting. Every app that can resolve the place is offered, except Apple Maps, which is dead weight on Android and Windows and therefore only appears on Apple platforms. iPadOS reports itself as a Mac; both sides of that ambiguity have the app, so the check does not need to tell them apart. The picker is a new component rather than the context menu, which has no notion of a submenu. It is portalled because both call sites sit in panels that clip their overflow, and it is measured before it is painted: near the bottom of the window it flips above the trigger instead of running off the screen, and it is pulled back inside when it would overflow to the right. With a single app left to offer there is no menu at all, the button opens it directly, exactly as it behaved before. The three surfaces take it the way each has room for. The inspector button collects them behind one entry, the day-plan context menu lists them flat because a menu can grow, and the phone keeps its single circle and opens the picker as a sheet. * Move OpenStreetMap into the navigation menu, and name the destination OSM was still a button of its own beside the one that now collects the map apps, which is the arrangement the menu exists to end. It belongs with the others. The bigger correction is what Waze and Apple Maps are handed. Both document a query parameter next to the position, and the pair is what makes a destination legible: coordinates say which place is meant, the name is what the driver reads instead of a number. Neither gets a name without a position, because there are a lot of places called "Bahnhof". Google is unchanged; it can already do better than a name through its place id. * Stop drawing the section count as a pill inside a pill SectionHeader wraps its count in a CountPill, and all three callers passed one in already, so every section header carried two stacked pills: a lighter rounded background peeking out from behind the real one. Pass the number and let the header do the wrapping. * Give the traveler filter a label and a way back The avatars floated between the toolbar and the first section with nothing to say what they were, and once a filter was on the only way out was remembering which face had been tapped. It is a labelled bar now, closed off by the same divider the rows below use, with an explicit reset that appears while a filter is active. Both texts already exist in all 23 languages. * Line the traveler filter up with the section titles It carried the sheet's usual 18px of horizontal padding while SectionHeader right below it starts at 2, so the two labels sat on different left edges. * Filter the place list by stars instead of sorting by them Sorting put the best-rated place first and left every other one on the list, which does not help when the question is "what does the group actually want to do". It is a floor now, the same one the collections bar uses: All, or 5+ down to 1+, with unrated places dropping out once a floor is set. The button carries the active floor so the list is never quietly shortened. The row it sits in used to render only when the trip had categories, which hid the star control on a fresh trip. That is exactly where someone collects places to rate, so the row is always there now and only the category dropdown depends on there being categories. places.sortByRating is replaced by places.filterByRating in all 23 languages, since the old label described a behaviour that no longer exists. * Keep the collections map when a filter matches nothing Clicking a label with no places behind it made the map disappear and the page reflow to full width. The layout hung off the FILTERED set: no visible places meant nothing mappable meant no split. A filter should empty the map, not tear it out, so the layout now asks whether the list has anything mappable at all, while the markers keep following the filter. * Give the label filters a tray of their own Two changes to the filter row. Select and manage-labels drop their captions and keep icon, title and aria-label, which puts them on the same footing as the add-place button that was already icon-only. The labels themselves were pill-shaped buttons with their own borders sitting loose among square-cornered dropdowns, so a list with a handful of them read as a pile rather than as one control. They share the filter buttons' radius and type now, and sit in a recessed strip led by a tag glyph: the chips carry no border of their own, because a frame inside a frame was half the noise. Manage stays quiet at the end of the strip until hovered. * Put the collections controls on one line The filter row sat at about 31px while the buttons floating over the map are 38, and the icon-only add button was the odd one out even within its own row. Scrolled to the top, where the row and the map's top bar are side by side, that reads as three different heights. One --col-ctl-h for both, fixed rather than derived from padding so a button with an icon and one with a word cannot drift apart, and the row starts at the same offset the map's own bar does. Phones raise the variable instead of patching individual min-heights, which keeps the square buttons square. * Offer Apple Maps on desktops that are not Apple Apple Maps has had a web version since 2024, so the link resolves to a usable map on Windows and Linux rather than to nothing. Hiding it there was a guess about a limitation that no longer exists. Android keeps the exemption: the web version works there too, but nobody navigating from an Android phone reaches for Apple Maps, and the row of choices is short on purpose. * Move the label filter into the map's top bar The filter row was running out of room while the map's own bar had space to spare, so the labels ride up there next to the collapse button, in the same glass as the controls beside them and scrolling sideways rather than wrapping. They fall back into the filter row whenever no map is on screen — a narrow window in list view, or a list with nothing mappable in it — so they can never end up somewhere the user cannot reach. Both places render the same component now instead of two copies drifting apart. * Stop the active select button drawing a dark icon on a dark fill Two rules with the same specificity claimed the same class. On a dropdown `.open` means the panel is down, so the label stays dark; on the select button it meant the mode is on, which fills it with the accent and needs the light text. The dropdown rule sits later in the file, so it won, and the icon disappeared into the fill the moment the button was clicked. The state is `.on` now, which is what the other toggles on this page (col-map-btn, col-iconbtn) already call it. * Open the place in Google Maps, not the coordinates Reported in #1278 and still reproducible: without a Google identifier the link carried nothing but latitude and longitude, so Maps dropped an unlabelled pin instead of opening the entry. The name and the address were both on hand and both thrown away. Google documents query=PLACE_NAME,ADDRESS for exactly this case, so that is what the fallback sends now. A name with no address still falls through to coordinates, because "Central Station" on its own would confidently open the wrong city, and a pin in the right spot beats a link to the wrong place. The precise paths are untouched: an ftid or a place id still wins, and they are what a place searched through Google carries. This only changes what happens for the ones that carry neither, which is every place added from OpenStreetMap, by hand, or before those fields existed.
735 lines
36 KiB
TypeScript
735 lines
36 KiB
TypeScript
// FE-COMP-PLACES-001 to FE-COMP-PLACES-015 + FE-PLANNER-SIDEBAR-016 to 043
|
|
import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { http, HttpResponse } from 'msw';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
import { useTripStore } from '../../store/tripStore';
|
|
import { usePermissionsStore } from '../../store/permissionsStore';
|
|
import { placesApi } from '../../api/client';
|
|
import { installTouchDragBridge } from '../../utils/touchDragBridge';
|
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
|
import { buildUser, buildTrip, buildPlace, buildCategory, buildDay, buildAssignment } from '../../../tests/helpers/factories';
|
|
import { server } from '../../../tests/helpers/msw/server';
|
|
import PlacesSidebar from './PlacesSidebar';
|
|
|
|
// Mock photoService so PlaceAvatar doesn't trigger API calls
|
|
vi.mock('../../services/photoService', () => ({
|
|
getCached: vi.fn(() => null),
|
|
isLoading: vi.fn(() => false),
|
|
fetchPhoto: vi.fn(),
|
|
onThumbReady: vi.fn(() => () => {}),
|
|
}));
|
|
|
|
// PlaceAvatar uses `new IntersectionObserver(...)` — needs a class-based mock
|
|
class MockIO {
|
|
observe = vi.fn();
|
|
disconnect = vi.fn();
|
|
unobserve = vi.fn();
|
|
}
|
|
beforeAll(() => { (globalThis as any).IntersectionObserver = MockIO; });
|
|
|
|
const defaultProps = {
|
|
tripId: 1,
|
|
places: [],
|
|
categories: [],
|
|
assignments: {},
|
|
selectedDayId: null,
|
|
selectedPlaceId: null,
|
|
onPlaceClick: vi.fn(),
|
|
onAddPlace: vi.fn(),
|
|
onAssignToDay: vi.fn(),
|
|
onEditPlace: vi.fn(),
|
|
onDeletePlace: vi.fn(),
|
|
days: [],
|
|
isMobile: false,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
|
});
|
|
|
|
describe('PlacesSidebar', () => {
|
|
it('FE-COMP-PLACES-001: renders without crashing', () => {
|
|
render(<PlacesSidebar {...defaultProps} />);
|
|
expect(document.body).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-002: shows search input', () => {
|
|
render(<PlacesSidebar {...defaultProps} />);
|
|
const searchInput = screen.getByPlaceholderText(/Search places/i);
|
|
expect(searchInput).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-003: renders places from props', () => {
|
|
const places = [
|
|
buildPlace({ name: 'Eiffel Tower' }),
|
|
buildPlace({ name: 'Louvre Museum' }),
|
|
];
|
|
render(<PlacesSidebar {...defaultProps} places={places} />);
|
|
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument();
|
|
expect(screen.getByText('Louvre Museum')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-004: shows Add Place button', () => {
|
|
render(<PlacesSidebar {...defaultProps} />);
|
|
// Multiple "Add Place/Activity" buttons may exist (top toolbar + empty state)
|
|
const addBtns = screen.getAllByText(/Add Place\/Activity/i);
|
|
expect(addBtns.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('FE-COMP-PLACES-005: clicking Add Place calls onAddPlace', async () => {
|
|
const user = userEvent.setup();
|
|
const onAddPlace = vi.fn();
|
|
render(<PlacesSidebar {...defaultProps} onAddPlace={onAddPlace} />);
|
|
const addBtns = screen.getAllByText(/Add Place\/Activity/i);
|
|
await user.click(addBtns[0]);
|
|
expect(onAddPlace).toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-006: clicking a place calls onPlaceClick with place id', async () => {
|
|
const user = userEvent.setup();
|
|
const onPlaceClick = vi.fn();
|
|
const place = buildPlace({ id: 42, name: 'Notre Dame' });
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} onPlaceClick={onPlaceClick} />);
|
|
await user.click(screen.getByText('Notre Dame'));
|
|
expect(onPlaceClick).toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-007: search filters places by name', async () => {
|
|
const user = userEvent.setup();
|
|
const places = [
|
|
buildPlace({ name: 'Arc de Triomphe' }),
|
|
buildPlace({ name: 'Sacre Coeur' }),
|
|
];
|
|
render(<PlacesSidebar {...defaultProps} places={places} />);
|
|
const searchInput = screen.getByPlaceholderText(/Search places/i);
|
|
await user.type(searchInput, 'Arc');
|
|
expect(screen.getByText('Arc de Triomphe')).toBeInTheDocument();
|
|
expect(screen.queryByText('Sacre Coeur')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-008: search is case-insensitive', async () => {
|
|
const user = userEvent.setup();
|
|
const places = [buildPlace({ name: 'Museum of Art' })];
|
|
render(<PlacesSidebar {...defaultProps} places={places} />);
|
|
const searchInput = screen.getByPlaceholderText(/Search places/i);
|
|
await user.type(searchInput, 'museum');
|
|
expect(screen.getByText('Museum of Art')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-009: selected place is highlighted', () => {
|
|
const place = buildPlace({ id: 10, name: 'Central Park' });
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} selectedPlaceId={10} />);
|
|
expect(screen.getByText('Central Park')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-009a: selected visible place is scrolled into view', async () => {
|
|
const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType<typeof vi.fn>;
|
|
scrollIntoView.mockClear();
|
|
const places = [
|
|
buildPlace({ id: 10, name: 'First Place' }),
|
|
buildPlace({ id: 42, name: 'Map Click Target' }),
|
|
];
|
|
|
|
render(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={42} />);
|
|
|
|
const selectedRow = screen.getByText('Map Click Target').closest('[data-place-id="42"]');
|
|
expect(selectedRow).toHaveAttribute('aria-selected', 'true');
|
|
await waitFor(() => {
|
|
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
|
|
});
|
|
});
|
|
|
|
it('FE-COMP-PLACES-009b: selected place hidden by search is not scrolled', async () => {
|
|
const user = userEvent.setup();
|
|
const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType<typeof vi.fn>;
|
|
const places = [
|
|
buildPlace({ id: 10, name: 'Visible Cafe' }),
|
|
buildPlace({ id: 42, name: 'Hidden Museum' }),
|
|
];
|
|
const { rerender } = render(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={null} />);
|
|
|
|
await user.type(screen.getByPlaceholderText(/Search places/i), 'Visible');
|
|
scrollIntoView.mockClear();
|
|
rerender(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={42} />);
|
|
|
|
expect(screen.queryByText('Hidden Museum')).not.toBeInTheDocument();
|
|
expect(scrollIntoView).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-010: shows place count', () => {
|
|
const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })];
|
|
render(<PlacesSidebar {...defaultProps} places={places} />);
|
|
// i18n: places.count = "{count} places"
|
|
expect(screen.getByText(/3 places/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-011: empty list shows no place names', () => {
|
|
render(<PlacesSidebar {...defaultProps} places={[]} />);
|
|
expect(screen.queryByText(/Eiffel/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-012: categories from props render without error', () => {
|
|
const cats = [buildCategory({ name: 'Restaurant' }), buildCategory({ name: 'Hotel' })];
|
|
render(<PlacesSidebar {...defaultProps} categories={cats} />);
|
|
expect(document.body).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-013: clearing search shows all places again', async () => {
|
|
const user = userEvent.setup();
|
|
const places = [buildPlace({ name: 'Place A' }), buildPlace({ name: 'Place B' })];
|
|
render(<PlacesSidebar {...defaultProps} places={places} />);
|
|
const searchInput = screen.getByPlaceholderText(/Search places/i);
|
|
await user.type(searchInput, 'Place A');
|
|
expect(screen.queryByText('Place B')).not.toBeInTheDocument();
|
|
await user.clear(searchInput);
|
|
expect(screen.getByText('Place B')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-014: renders with days prop for day assignment', () => {
|
|
const days = [buildDay({ id: 1, date: '2025-06-01' })];
|
|
render(<PlacesSidebar {...defaultProps} days={days} />);
|
|
expect(document.body).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACES-015: onEditPlace passed to component correctly', () => {
|
|
const onEditPlace = vi.fn();
|
|
const place = buildPlace({ name: 'Test Place' });
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} onEditPlace={onEditPlace} />);
|
|
expect(screen.getByText('Test Place')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// ── Filter tabs ───────────────────────────────────────────────────────────────
|
|
|
|
describe('Filter tabs', () => {
|
|
it('FE-PLANNER-SIDEBAR-016: "All" tab is active by default', () => {
|
|
const places = [buildPlace({ name: 'Place Alpha' }), buildPlace({ name: 'Place Beta' })];
|
|
render(<PlacesSidebar {...defaultProps} places={places} />);
|
|
expect(screen.getByText('Place Alpha')).toBeInTheDocument();
|
|
expect(screen.getByText('Place Beta')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-017: "Unplanned" tab filters out planned places', async () => {
|
|
const user = userEvent.setup();
|
|
const planned = buildPlace({ name: 'Planned Place' });
|
|
const unplanned = buildPlace({ name: 'Unplanned Place' });
|
|
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
|
|
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
|
|
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
|
|
expect(screen.queryByText('Planned Place')).not.toBeInTheDocument();
|
|
expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-018: "All" tab re-shows planned places', async () => {
|
|
const user = userEvent.setup();
|
|
const planned = buildPlace({ name: 'Planned Place' });
|
|
const unplanned = buildPlace({ name: 'Unplanned Place' });
|
|
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
|
|
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
|
|
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
|
|
await user.click(screen.getByRole('button', { name: /^All/i }));
|
|
expect(screen.getByText('Planned Place')).toBeInTheDocument();
|
|
expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-019: unplanned empty state shows "All places are planned"', async () => {
|
|
const user = userEvent.setup();
|
|
const place = buildPlace({ name: 'Assigned Place' });
|
|
const assignments = { '1': [buildAssignment({ place, day_id: 1 })] };
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} assignments={assignments} />);
|
|
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
|
|
expect(screen.getByText(/All places are planned/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-019b: "Planned" filter shows only planned places', () => {
|
|
const planned = buildPlace({ name: 'Planned Place' });
|
|
const unplanned = buildPlace({ name: 'Unplanned Place' });
|
|
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
|
|
// Seed the pool filter directly: the tab label resolves from @trek/shared, but the
|
|
// filter behaviour (only day-assigned places survive) is what this guards.
|
|
seedStore(useTripStore, { placesFilter: 'planned' });
|
|
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
|
|
expect(screen.getByText('Planned Place')).toBeInTheDocument();
|
|
expect(screen.queryByText('Unplanned Place')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// ── Search ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Search', () => {
|
|
it('FE-PLANNER-SIDEBAR-020: search filters by address', async () => {
|
|
const user = userEvent.setup();
|
|
const place = buildPlace({ name: 'UK Office', address: '10 Downing Street' });
|
|
const other = buildPlace({ name: 'Other Place', address: null });
|
|
render(<PlacesSidebar {...defaultProps} places={[place, other]} />);
|
|
await user.type(screen.getByPlaceholderText(/Search places/i), 'Downing');
|
|
expect(screen.getByText('UK Office')).toBeInTheDocument();
|
|
expect(screen.queryByText('Other Place')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-021: clear search (X) button appears and resets search', async () => {
|
|
const user = userEvent.setup();
|
|
const places = [buildPlace({ name: 'Paris Hotel' }), buildPlace({ name: 'Rome Cafe' })];
|
|
render(<PlacesSidebar {...defaultProps} places={places} />);
|
|
const searchInput = screen.getByPlaceholderText(/Search places/i);
|
|
await user.type(searchInput, 'Paris');
|
|
expect(screen.queryByText('Rome Cafe')).not.toBeInTheDocument();
|
|
// X clear button should appear
|
|
const clearBtn = document.querySelector('button svg[data-lucide="x"]')?.closest('button')
|
|
?? document.querySelector('input[type="text"] ~ button')
|
|
?? screen.getByRole('button', { name: '' });
|
|
// Find the X button by querying near the search input
|
|
const inputWrapper = searchInput.closest('div');
|
|
const xBtn = inputWrapper?.querySelector('button');
|
|
expect(xBtn).toBeTruthy();
|
|
await user.click(xBtn!);
|
|
expect(screen.getByText('Rome Cafe')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// ── Category filter dropdown ──────────────────────────────────────────────────
|
|
|
|
describe('Category filter dropdown', () => {
|
|
it('FE-PLANNER-SIDEBAR-022: category dropdown renders when categories are present', () => {
|
|
const cat = buildCategory({ name: 'Museum', color: '#3b82f6' });
|
|
render(<PlacesSidebar {...defaultProps} categories={[cat]} />);
|
|
expect(screen.getByText(/All Categories/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-023: clicking category dropdown opens options', async () => {
|
|
const user = userEvent.setup();
|
|
const cat = buildCategory({ name: 'Museum', color: '#3b82f6' });
|
|
render(<PlacesSidebar {...defaultProps} categories={[cat]} />);
|
|
await user.click(screen.getByText(/All Categories/i));
|
|
expect(screen.getByText('Museum')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-024: selecting a category filters places', async () => {
|
|
const user = userEvent.setup();
|
|
const cat = buildCategory({ name: 'Park', color: '#22c55e' });
|
|
// Give places addresses so category name doesn't appear as subtitle
|
|
const withCat = buildPlace({ name: 'Central Park', category_id: cat.id, address: 'New York, NY' });
|
|
const noCat = buildPlace({ name: 'Random Shop', category_id: null, address: 'London, UK' });
|
|
render(<PlacesSidebar {...defaultProps} places={[withCat, noCat]} categories={[cat]} />);
|
|
await user.click(screen.getByText(/All Categories/i));
|
|
// Click the category option in the dropdown (only one 'Park' now — no subtitle conflict)
|
|
await user.click(screen.getByText('Park'));
|
|
expect(screen.getByText('Central Park')).toBeInTheDocument();
|
|
expect(screen.queryByText('Random Shop')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-025: "Clear filter" button appears when filter active and clears it', async () => {
|
|
const user = userEvent.setup();
|
|
const cat = buildCategory({ name: 'Museum', color: '#3b82f6' });
|
|
// Give places addresses so category name doesn't appear as subtitle
|
|
const withCat = buildPlace({ name: 'Art Museum', category_id: cat.id, address: 'Paris' });
|
|
const noCat = buildPlace({ name: 'Untagged Place', category_id: null, address: 'Berlin' });
|
|
render(<PlacesSidebar {...defaultProps} places={[withCat, noCat]} categories={[cat]} />);
|
|
await user.click(screen.getByText(/All Categories/i));
|
|
await user.click(screen.getByText('Museum'));
|
|
expect(screen.queryByText('Untagged Place')).not.toBeInTheDocument();
|
|
// Clear filter button should appear
|
|
expect(screen.getByText(/Clear filter/i)).toBeInTheDocument();
|
|
await user.click(screen.getByText(/Clear filter/i));
|
|
expect(screen.getByText('Untagged Place')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-026: multi-category selection shows count', async () => {
|
|
const user = userEvent.setup();
|
|
const cat1 = buildCategory({ name: 'Museum', color: '#3b82f6' });
|
|
const cat2 = buildCategory({ name: 'Park', color: '#22c55e' });
|
|
render(<PlacesSidebar {...defaultProps} categories={[cat1, cat2]} />);
|
|
await user.click(screen.getByText(/All Categories/i));
|
|
const museumOpts = screen.getAllByText('Museum');
|
|
await user.click(museumOpts[museumOpts.length - 1]);
|
|
const parkOpts = screen.getAllByText('Park');
|
|
await user.click(parkOpts[parkOpts.length - 1]);
|
|
expect(screen.getByText(/2 categories/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-047: category filter survives unmount/remount (#1541)', async () => {
|
|
const user = userEvent.setup();
|
|
const cat = buildCategory({ name: 'Hotel', color: '#3b82f6' });
|
|
const withCat = buildPlace({ name: 'Grand Palace', category_id: cat.id, address: 'Vienna' });
|
|
const noCat = buildPlace({ name: 'Street Market', category_id: null, address: 'Lisbon' });
|
|
const { unmount } = render(<PlacesSidebar {...defaultProps} places={[withCat, noCat]} categories={[cat]} />);
|
|
await user.click(screen.getByText(/All Categories/i));
|
|
await user.click(screen.getByText('Hotel'));
|
|
expect(screen.queryByText('Street Market')).not.toBeInTheDocument();
|
|
// Switching planner tabs unmounts the sidebar; the filter must come back
|
|
// both applied and visible instead of silently sticking on the map only.
|
|
unmount();
|
|
render(<PlacesSidebar {...defaultProps} places={[withCat, noCat]} categories={[cat]} />);
|
|
expect(screen.queryByText(/All Categories/i)).not.toBeInTheDocument();
|
|
expect(screen.getByText('Hotel')).toBeInTheDocument();
|
|
expect(screen.getByText('Grand Palace')).toBeInTheDocument();
|
|
expect(screen.queryByText('Street Market')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-048: filter tab survives unmount/remount (#1541)', async () => {
|
|
const user = userEvent.setup();
|
|
const planned = buildPlace({ name: 'Planned Place' });
|
|
const unplanned = buildPlace({ name: 'Unplanned Place' });
|
|
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
|
|
const { unmount } = render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
|
|
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
|
|
expect(screen.queryByText('Planned Place')).not.toBeInTheDocument();
|
|
unmount();
|
|
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
|
|
expect(screen.queryByText('Planned Place')).not.toBeInTheDocument();
|
|
expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// ── Place list interaction ─────────────────────────────────────────────────────
|
|
|
|
describe('Place list interaction', () => {
|
|
it('FE-PLANNER-SIDEBAR-027: "+" assign button appears when selectedDayId set and place not in day', () => {
|
|
const place = buildPlace({ name: 'Unassigned Place' });
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} selectedDayId={5} assignments={{}} />);
|
|
// Plus button should be visible next to the place
|
|
const plusBtns = screen.getAllByRole('button');
|
|
const plusBtn = plusBtns.find(b => b.querySelector('svg'));
|
|
expect(plusBtn).toBeTruthy();
|
|
// The place row itself should be in the DOM
|
|
expect(screen.getByText('Unassigned Place')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-028: clicking "+" assign button calls onAssignToDay with placeId', async () => {
|
|
const user = userEvent.setup();
|
|
const onAssignToDay = vi.fn();
|
|
const place = buildPlace({ id: 99, name: 'Place To Assign' });
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} selectedDayId={5} assignments={{}} onAssignToDay={onAssignToDay} />);
|
|
// Find the + button inside the place row (small inline button)
|
|
const placeRow = screen.getByText('Place To Assign').closest('div[draggable]')!;
|
|
const plusBtn = placeRow.querySelector('button')!;
|
|
await user.click(plusBtn);
|
|
expect(onAssignToDay).toHaveBeenCalledWith(99);
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-029: "+" button not shown when place already assigned to selectedDay', () => {
|
|
const place = buildPlace({ id: 55, name: 'Already Assigned' });
|
|
const assignments = { '5': [buildAssignment({ place, day_id: 5 })] };
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} selectedDayId={5} assignments={assignments} />);
|
|
const placeRow = screen.getByText('Already Assigned').closest('div[draggable]')!;
|
|
const plusBtn = placeRow.querySelector('button');
|
|
expect(plusBtn).toBeNull();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-030: place address shown as subtitle', () => {
|
|
const place = buildPlace({ name: 'Paris Spot', address: 'Rue de Rivoli', description: null });
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} />);
|
|
expect(screen.getByText('Rue de Rivoli')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-031: no edit buttons shown when canEditPlaces=false', () => {
|
|
seedStore(usePermissionsStore, { permissions: { place_edit: 'admin' } });
|
|
render(<PlacesSidebar {...defaultProps} />);
|
|
expect(screen.queryByText(/Add Place\/Activity/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/GPX/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/Google List/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-032: place count shows singular form for 1 place', () => {
|
|
const place = buildPlace({ name: 'Solo Place' });
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} />);
|
|
expect(screen.getByText('1 place')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// ── Mobile day-picker (portal) ─────────────────────────────────────────────────
|
|
|
|
describe('Mobile day-picker (portal)', () => {
|
|
it('FE-PLANNER-SIDEBAR-033: on mobile, clicking a place opens day-picker bottom sheet', async () => {
|
|
const user = userEvent.setup();
|
|
const place = buildPlace({ name: 'Mobile Place' });
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} isMobile={true} />);
|
|
await user.click(screen.getByText('Mobile Place'));
|
|
// The bottom sheet portal renders an extra copy of the place name + action buttons
|
|
expect(await screen.findAllByText('Mobile Place')).toHaveLength(2);
|
|
// Sheet-specific button is always present
|
|
expect(screen.getByText(/View details/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-034: day-picker lists days and clicking a day calls onAssignToDay', async () => {
|
|
const user = userEvent.setup();
|
|
const onAssignToDay = vi.fn();
|
|
const place = buildPlace({ id: 77, name: 'Day Picker Place' });
|
|
const day = buildDay({ id: 7, title: 'Day 1' });
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} isMobile={true} days={[day]} onAssignToDay={onAssignToDay} />);
|
|
await user.click(screen.getByText('Day Picker Place'));
|
|
// Click "Add to which day?" to expand the day list
|
|
const assignBtn = await screen.findByText(/Add to which day\?/i);
|
|
await user.click(assignBtn);
|
|
// Click Day 1
|
|
expect(await screen.findByText('Day 1')).toBeInTheDocument();
|
|
await user.click(screen.getByText('Day 1'));
|
|
expect(onAssignToDay).toHaveBeenCalledWith(77, 7);
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-035: day-picker backdrop click dismisses sheet', async () => {
|
|
const user = userEvent.setup();
|
|
const place = buildPlace({ name: 'Dismissable Place' });
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} isMobile={true} />);
|
|
await user.click(screen.getByText('Dismissable Place'));
|
|
// Wait for the sheet to open (always shows "View details")
|
|
await screen.findByText(/View details/i);
|
|
expect(screen.getAllByText('Dismissable Place')).toHaveLength(2);
|
|
// Click the backdrop (fixed overlay div — first fixed overlay in body)
|
|
const backdrop = document.querySelector('[style*="position: fixed"][style*="inset: 0"]') as HTMLElement;
|
|
expect(backdrop).toBeTruthy();
|
|
await user.click(backdrop!);
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/View details/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-036: day-picker Edit button calls onEditPlace', async () => {
|
|
const user = userEvent.setup();
|
|
const onEditPlace = vi.fn();
|
|
const place = buildPlace({ id: 88, name: 'Editable Place' });
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} isMobile={true} onEditPlace={onEditPlace} />);
|
|
await user.click(screen.getByText('Editable Place'));
|
|
const editBtn = await screen.findByText(/^Edit$/i);
|
|
await user.click(editBtn);
|
|
expect(onEditPlace).toHaveBeenCalledWith(expect.objectContaining({ id: 88 }));
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-037: day-picker Delete button calls onDeletePlace', async () => {
|
|
const user = userEvent.setup();
|
|
const onDeletePlace = vi.fn();
|
|
const place = buildPlace({ id: 66, name: 'Deletable Place' });
|
|
render(<PlacesSidebar {...defaultProps} places={[place]} isMobile={true} onDeletePlace={onDeletePlace} />);
|
|
await user.click(screen.getByText('Deletable Place'));
|
|
const deleteBtn = await screen.findByText(/^Delete$/i);
|
|
await user.click(deleteBtn);
|
|
expect(onDeletePlace).toHaveBeenCalledWith(66);
|
|
});
|
|
});
|
|
|
|
// ── GPX import ────────────────────────────────────────────────────────────────
|
|
|
|
describe('GPX import', () => {
|
|
it('FE-PLANNER-SIDEBAR-038: "Import file" button opens the file import modal', async () => {
|
|
const user = userEvent.setup();
|
|
render(<PlacesSidebar {...defaultProps} />);
|
|
await user.click(screen.getByText(/Import file/i));
|
|
expect(await screen.findByText(/\.gpx.*\.kml.*\.kmz/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-039: successful GPX import via modal shows success toast', async () => {
|
|
const importSpy = vi.spyOn(placesApi, 'importGpx').mockResolvedValueOnce({ count: 2, places: [{ id: 10 }, { id: 11 }] });
|
|
const loadTrip = vi.fn().mockResolvedValue(undefined);
|
|
seedStore(useTripStore, { loadTrip });
|
|
const addToast = vi.fn();
|
|
(window as any).__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
|
|
await user.click(screen.getByText(/Import file/i));
|
|
const fileInput = document.querySelector('input[type="file"][accept=".gpx,.kml,.kmz"]') as HTMLInputElement;
|
|
expect(fileInput).toBeTruthy();
|
|
const file = new File(['track data'], 'route.gpx', { type: 'application/gpx+xml' });
|
|
await act(async () => {
|
|
fireEvent.change(fileInput, { target: { files: [file] } });
|
|
});
|
|
await user.click(screen.getByRole('button', { name: /^import$/i }));
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith(
|
|
expect.stringContaining('2'),
|
|
'success',
|
|
undefined,
|
|
);
|
|
});
|
|
importSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ── Google Maps list import ───────────────────────────────────────────────────
|
|
|
|
describe('Google Maps list import', () => {
|
|
it('FE-PLANNER-SIDEBAR-040: "Google List" button opens the URL dialog', async () => {
|
|
const user = userEvent.setup();
|
|
render(<PlacesSidebar {...defaultProps} />);
|
|
await user.click(screen.getByText(/List Import/i));
|
|
expect(await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-041: import button disabled when URL input is empty', async () => {
|
|
const user = userEvent.setup();
|
|
render(<PlacesSidebar {...defaultProps} />);
|
|
await user.click(screen.getByText(/List Import/i));
|
|
await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
|
|
const importBtn = screen.getByRole('button', { name: /^Import$/i });
|
|
expect(importBtn).toBeDisabled();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-042: successful Google list import shows success toast and closes dialog', async () => {
|
|
server.use(
|
|
http.post('/api/trips/1/places/import/google-list', () =>
|
|
HttpResponse.json({ count: 3, listName: 'My List', places: [{ id: 20 }, { id: 21 }, { id: 22 }] })
|
|
),
|
|
);
|
|
const loadTrip = vi.fn().mockResolvedValue(undefined);
|
|
seedStore(useTripStore, { loadTrip });
|
|
const addToast = vi.fn();
|
|
(window as any).__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
|
|
await user.click(screen.getByText(/List Import/i));
|
|
const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
|
|
await user.type(urlInput, 'https://maps.app.goo.gl/abc123');
|
|
await user.click(screen.getByRole('button', { name: /^Import$/i }));
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith(
|
|
expect.stringContaining('3'),
|
|
'success',
|
|
undefined,
|
|
);
|
|
});
|
|
// Dialog should close
|
|
await waitFor(() => {
|
|
expect(screen.queryByPlaceholderText(/maps\.app\.goo\.gl/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-043: pressing Enter in URL field triggers import', async () => {
|
|
server.use(
|
|
http.post('/api/trips/1/places/import/google-list', () =>
|
|
HttpResponse.json({ count: 1, listName: 'Test', places: [{ id: 30 }] })
|
|
),
|
|
);
|
|
const loadTrip = vi.fn().mockResolvedValue(undefined);
|
|
seedStore(useTripStore, { loadTrip });
|
|
const addToast = vi.fn();
|
|
(window as any).__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
|
|
await user.click(screen.getByText(/List Import/i));
|
|
const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i);
|
|
await user.type(urlInput, 'https://maps.app.goo.gl/xyz{Enter}');
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith(
|
|
expect.stringContaining('1'),
|
|
'success',
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
});
|
|
|
|
// #1616: a tablet is a coarse pointer at a desktop width, and it sees both panes, so
|
|
// it has somewhere to drag a place to. A coarse pointer used to switch the drag off by
|
|
// itself, which left the reporter's iPad selecting text instead of picking up a row.
|
|
// Width is the only gate now: below lg the places live in their own tab.
|
|
describe('touch device at desktop width (#1616)', () => {
|
|
const tabletProps = { ...defaultProps, isMobile: false };
|
|
|
|
it('FE-PLANNER-SIDEBAR-044: place rows are draggable and opt into the touch bridge', () => {
|
|
const place = buildPlace({ id: 7, name: 'Tablet Place' });
|
|
const { container } = render(<PlacesSidebar {...tabletProps} places={[place]} />);
|
|
const placeRow = screen.getByText('Tablet Place').closest('div[draggable]')!;
|
|
expect(placeRow.getAttribute('draggable')).toBe('true');
|
|
expect((container.firstChild as HTMLElement).hasAttribute('data-touch-drag')).toBe(true);
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-045: dragging over the sidebar raises the drop-to-import overlay', () => {
|
|
const place = buildPlace({ id: 7, name: 'Tablet Place' });
|
|
const { container } = render(<PlacesSidebar {...tabletProps} places={[place]} />);
|
|
fireEvent.dragEnter(container.firstChild as HTMLElement);
|
|
expect(screen.getByText('Drop to import')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-046: below lg the rows stay undraggable and the bridge stays out', () => {
|
|
const place = buildPlace({ id: 7, name: 'Narrow Place' });
|
|
const { container } = render(<PlacesSidebar {...defaultProps} isMobile places={[place]} />);
|
|
const placeRow = screen.getByText('Narrow Place').closest('div[draggable]')!;
|
|
expect(placeRow.getAttribute('draggable')).toBe('false');
|
|
expect((container.firstChild as HTMLElement).hasAttribute('data-touch-drag')).toBe(false);
|
|
fireEvent.dragEnter(container.firstChild as HTMLElement);
|
|
expect(screen.queryByText('Drop to import')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// The map draws no legend of its own, so the stroke in the row is the only
|
|
// thing tying a coloured line back to a place (#776).
|
|
describe('track colour legend (#776)', () => {
|
|
it('FE-PLANNER-SIDEBAR-049: a track row carries a stroke in the colour the map draws', () => {
|
|
const track = buildPlace({ id: 11, name: 'Coloured Track', route_geometry: '[[48.0,2.0],[49.0,3.0]]', route_color: '#e11d48' });
|
|
render(<PlacesSidebar {...defaultProps} places={[track]} />);
|
|
const row = screen.getByText('Coloured Track').closest('div[draggable]')!;
|
|
const strokes = Array.from(row.querySelectorAll('span')).filter(el => (el as HTMLElement).style.borderRadius === '999px');
|
|
expect(strokes.some(el => (el as HTMLElement).style.background.includes('225, 29, 72') || (el as HTMLElement).style.background.includes('#e11d48'))).toBe(true);
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-050: a place without geometry gets no stroke at all', () => {
|
|
const plain = buildPlace({ id: 12, name: 'Plain Place' });
|
|
render(<PlacesSidebar {...defaultProps} places={[plain]} />);
|
|
const row = screen.getByText('Plain Place').closest('div[draggable]')!;
|
|
const strokes = Array.from(row.querySelectorAll('span')).filter(el => (el as HTMLElement).style.borderRadius === '999px');
|
|
expect(strokes).toHaveLength(0);
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-051: the star button filters the list down to a minimum rating', async () => {
|
|
// Replaces the old sort toggle: sorting put the best first but still left
|
|
// every other place on the list, which is no help when the point is to see
|
|
// only what the group actually wants to do.
|
|
const user = userEvent.setup();
|
|
const places = [
|
|
buildPlace({ id: 20, name: 'Loved Place', rating_avg: 4.6 }),
|
|
buildPlace({ id: 21, name: 'Meh Place', rating_avg: 2.1 }),
|
|
buildPlace({ id: 22, name: 'Unrated Place' }),
|
|
];
|
|
render(<PlacesSidebar {...defaultProps} places={places} />);
|
|
expect(screen.getByText('Meh Place')).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByLabelText('Filter by rating'));
|
|
await user.click(screen.getByText('4+'));
|
|
|
|
expect(screen.getByText('Loved Place')).toBeInTheDocument();
|
|
expect(screen.queryByText('Meh Place')).not.toBeInTheDocument();
|
|
// An unrated place has no average to clear the floor with.
|
|
expect(screen.queryByText('Unrated Place')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// #1616 — the other half of the reporter's gesture: the pickup. A tablet cannot
|
|
// start an HTML5 drag with a finger, so the bridge's long press has to do it, and
|
|
// the row has to hand over the placeId the day plan reads back on drop.
|
|
describe('picking a place up with a finger (#1616)', () => {
|
|
it('FE-PLANNER-SIDEBAR-047: a long press on a place row starts a drag carrying its id', async () => {
|
|
const place = buildPlace({ id: 42, name: 'Tablet Place' });
|
|
render(<PlacesSidebar {...defaultProps} isMobile={false} places={[place]} />);
|
|
const teardown = installTouchDragBridge();
|
|
try {
|
|
const row = screen.getByText('Tablet Place').closest('[draggable="true"]')!;
|
|
fireEvent.touchStart(row, { touches: [{ identifier: 1, clientX: 20, clientY: 40 }] });
|
|
await new Promise(resolve => setTimeout(resolve, 400));
|
|
expect(window.__dragData).toEqual({ placeId: '42' });
|
|
} finally {
|
|
teardown();
|
|
window.__dragData = null;
|
|
}
|
|
});
|
|
|
|
it('FE-PLANNER-SIDEBAR-048: a swipe down the list scrolls instead of picking the row up', async () => {
|
|
const place = buildPlace({ id: 42, name: 'Tablet Place' });
|
|
render(<PlacesSidebar {...defaultProps} isMobile={false} places={[place]} />);
|
|
const teardown = installTouchDragBridge();
|
|
try {
|
|
const row = screen.getByText('Tablet Place').closest('[draggable="true"]')!;
|
|
fireEvent.touchStart(row, { touches: [{ identifier: 1, clientX: 20, clientY: 40 }] });
|
|
const moved = fireEvent.touchMove(document, { touches: [{ identifier: 1, clientX: 20, clientY: 140 }] });
|
|
await new Promise(resolve => setTimeout(resolve, 400));
|
|
expect(moved).toBe(true);
|
|
expect(window.__dragData).toBeFalsy();
|
|
} finally {
|
|
teardown();
|
|
window.__dragData = null;
|
|
}
|
|
});
|
|
});
|