Files
TREK/client/src/components/Planner/PlacesSidebarSelectionBar.test.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00

215 lines
8.9 KiB
TypeScript

// FE-PLANNER-SELBAR-001 to FE-PLANNER-SELBAR-015
import userEvent from '@testing-library/user-event';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { buildPlace } from '../../../tests/helpers/factories';
import { useTranslation } from '../../i18n';
import type { Place } from '../../types';
import { PlacesSelectionBar } from './PlacesSidebarSelectionBar';
import type { SidebarState } from './usePlacesSidebar';
const PLACES = [buildPlace({ id: 1 }), buildPlace({ id: 2 }), buildPlace({ id: 3 })];
interface BarProps {
selectedIds?: Set<number>;
filtered?: Place[];
isMobile?: boolean;
collectionsEnabled?: boolean;
setSelectedIds?: (ids: Set<number>) => void;
setPendingDeleteIds?: (ids: number[] | null) => void;
setCategoryPickerOpen?: (open: boolean) => void;
setSaveToListOpen?: (open: boolean) => void;
onBulkDeletePlaces?: (ids: number[]) => void;
markSelectionVisited?: () => Promise<void>;
markVisitedBusy?: boolean;
}
// The bar reads a handful of fields off the sidebar state; the rest of the hook
// is irrelevant here, so only those are supplied.
function Bar(overrides: BarProps) {
const { t } = useTranslation();
const state = {
t,
selectedIds: new Set<number>(),
filtered: PLACES,
isMobile: false,
collectionsEnabled: false,
setSelectedIds: () => {},
setPendingDeleteIds: () => {},
setCategoryPickerOpen: () => {},
setSaveToListOpen: () => {},
onBulkDeletePlaces: undefined,
markSelectionVisited: async () => {},
markVisitedBusy: false,
...overrides,
};
return <PlacesSelectionBar {...(state as unknown as SidebarState)} />;
}
describe('PlacesSelectionBar', () => {
it('FE-PLANNER-SELBAR-001: reports how many places are selected', () => {
render(<Bar selectedIds={new Set([1, 2])} />);
expect(screen.getByRole('status', { name: '2 selected' })).toHaveTextContent('2');
});
it('FE-PLANNER-SELBAR-002: offers "Select all" while the selection is partial', () => {
render(<Bar selectedIds={new Set([1])} />);
expect(screen.getByRole('button', { name: 'Select all' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Deselect all' })).not.toBeInTheDocument();
});
it('FE-PLANNER-SELBAR-003: "Select all" selects every visible place', async () => {
const user = userEvent.setup();
const setSelectedIds = vi.fn((_ids: Set<number>) => {});
render(<Bar selectedIds={new Set([1])} setSelectedIds={setSelectedIds} />);
await user.click(screen.getByRole('button', { name: 'Select all' }));
expect(setSelectedIds).toHaveBeenCalledWith(new Set([1, 2, 3]));
});
it('FE-PLANNER-SELBAR-004: a full selection flips the button to "Deselect all" and clears it', async () => {
const user = userEvent.setup();
const setSelectedIds = vi.fn((_ids: Set<number>) => {});
render(<Bar selectedIds={new Set([1, 2, 3])} setSelectedIds={setSelectedIds} />);
await user.click(screen.getByRole('button', { name: 'Deselect all' }));
expect(setSelectedIds).toHaveBeenCalledWith(new Set());
});
it('FE-PLANNER-SELBAR-005: an empty list keeps the "Select all" wording', async () => {
const user = userEvent.setup();
const setSelectedIds = vi.fn((_ids: Set<number>) => {});
render(<Bar filtered={[]} selectedIds={new Set()} setSelectedIds={setSelectedIds} />);
// 0 === 0 would otherwise read as "everything is selected".
const btn = screen.getByRole('button', { name: 'Select all' });
await user.click(btn);
expect(setSelectedIds).toHaveBeenCalledWith(new Set());
});
it('FE-PLANNER-SELBAR-006: the category button is disabled without a selection', () => {
const setCategoryPickerOpen = vi.fn((_open: boolean) => {});
render(<Bar selectedIds={new Set()} setCategoryPickerOpen={setCategoryPickerOpen} />);
const btn = screen.getByRole('button', { name: 'Change category' });
expect(btn).toBeDisabled();
fireEvent.click(btn);
expect(setCategoryPickerOpen).not.toHaveBeenCalled();
});
it('FE-PLANNER-SELBAR-007: the category button opens the bulk picker', async () => {
const user = userEvent.setup();
const setCategoryPickerOpen = vi.fn((_open: boolean) => {});
render(<Bar selectedIds={new Set([1])} setCategoryPickerOpen={setCategoryPickerOpen} />);
await user.click(screen.getByRole('button', { name: 'Change category' }));
expect(setCategoryPickerOpen).toHaveBeenCalledWith(true);
});
it('FE-PLANNER-SELBAR-008: the collection button is hidden while the addon is off', () => {
render(<Bar selectedIds={new Set([1])} collectionsEnabled={false} />);
expect(screen.queryByRole('button', { name: 'Save to Collection' })).not.toBeInTheDocument();
});
it('FE-PLANNER-SELBAR-009: the collection button opens the save sheet', async () => {
const user = userEvent.setup();
const setSaveToListOpen = vi.fn((_open: boolean) => {});
render(<Bar selectedIds={new Set([1])} collectionsEnabled setSaveToListOpen={setSaveToListOpen} />);
await user.click(screen.getByRole('button', { name: 'Save to Collection' }));
expect(setSaveToListOpen).toHaveBeenCalledWith(true);
});
it('FE-PLANNER-SELBAR-010: the collection button is disabled without a selection', () => {
const setSaveToListOpen = vi.fn((_open: boolean) => {});
render(<Bar selectedIds={new Set()} collectionsEnabled setSaveToListOpen={setSaveToListOpen} />);
const btn = screen.getByRole('button', { name: 'Save to Collection' });
expect(btn).toBeDisabled();
fireEvent.click(btn);
expect(setSaveToListOpen).not.toHaveBeenCalled();
});
it('FE-PLANNER-SELBAR-011: on desktop delete goes straight to the bulk handler', async () => {
const user = userEvent.setup();
const onBulkDeletePlaces = vi.fn((_ids: number[]) => {});
const setPendingDeleteIds = vi.fn((_ids: number[] | null) => {});
render(
<Bar
selectedIds={new Set([2, 3])}
onBulkDeletePlaces={onBulkDeletePlaces}
setPendingDeleteIds={setPendingDeleteIds}
/>,
);
await user.click(screen.getByRole('button', { name: 'Delete selected' }));
expect(onBulkDeletePlaces).toHaveBeenCalledWith([2, 3]);
expect(setPendingDeleteIds).not.toHaveBeenCalled();
});
it('FE-PLANNER-SELBAR-012: on mobile delete arms the confirm dialog instead', async () => {
const user = userEvent.setup();
const onBulkDeletePlaces = vi.fn((_ids: number[]) => {});
const setPendingDeleteIds = vi.fn((_ids: number[] | null) => {});
render(
<Bar
isMobile
selectedIds={new Set([2, 3])}
onBulkDeletePlaces={onBulkDeletePlaces}
setPendingDeleteIds={setPendingDeleteIds}
/>,
);
await user.click(screen.getByRole('button', { name: 'Delete selected' }));
expect(setPendingDeleteIds).toHaveBeenCalledWith([2, 3]);
expect(onBulkDeletePlaces).not.toHaveBeenCalled();
});
it('FE-PLANNER-SELBAR-013: hovering an action shows its name as a tooltip, and leaving hides it', async () => {
const user = userEvent.setup();
render(<Bar selectedIds={new Set([1])} collectionsEnabled />);
for (const name of ['Select all', 'Change category', 'Save to Collection', 'Mark visited in your lists', 'Delete selected']) {
const btn = screen.getByRole('button', { name });
expect(btn).toBeEnabled();
await user.hover(btn);
expect(await screen.findByRole('tooltip')).toHaveTextContent(name);
await user.unhover(btn);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
}
});
it('FE-PLANNER-SELBAR-014: without a selection every action but "Select all" is inert', () => {
const onBulkDeletePlaces = vi.fn((_ids: number[]) => {});
const markSelectionVisited = vi.fn(async () => {});
render(<Bar selectedIds={new Set()} collectionsEnabled onBulkDeletePlaces={onBulkDeletePlaces} markSelectionVisited={markSelectionVisited} />);
expect(screen.getByRole('button', { name: 'Select all' })).toBeEnabled();
for (const name of ['Change category', 'Save to Collection', 'Mark visited in your lists', 'Delete selected']) {
const btn = screen.getByRole('button', { name });
expect(btn).toBeDisabled();
fireEvent.click(btn);
}
expect(onBulkDeletePlaces).not.toHaveBeenCalled();
expect(markSelectionVisited).not.toHaveBeenCalled();
});
it('FE-PLANNER-SELBAR-015: "mark visited" runs for the selection and is locked while it runs', async () => {
const user = userEvent.setup();
const markSelectionVisited = vi.fn(async () => {});
const { rerender } = render(<Bar selectedIds={new Set([1, 2])} collectionsEnabled markSelectionVisited={markSelectionVisited} />);
await user.click(screen.getByRole('button', { name: 'Mark visited in your lists' }));
expect(markSelectionVisited).toHaveBeenCalledTimes(1);
rerender(<Bar selectedIds={new Set([1, 2])} collectionsEnabled markSelectionVisited={markSelectionVisited} markVisitedBusy />);
expect(screen.getByRole('button', { name: 'Mark visited in your lists' })).toBeDisabled();
});
});