test(collections): client component tests + select in All saved, off the map (#1081)

- Add client tests for the new collections UI (80 tests): collectionsModel (incl.
  the undefined-entry guards that fix the white-screen regression), StatusBadge,
  CollectionFilterBar, CollectionList, CollectionPlaceDetail (permission gating),
  MoveToListModal.
- Offer the select toggle in "All saved" too (server enforces per-place rights).
- Drop the now-duplicate select button from the map controls (it lives in the
  filter row).
This commit is contained in:
Maurice
2026-07-01 17:56:37 +02:00
committed by Maurice
parent baf156a36b
commit 8014264b21
8 changed files with 981 additions and 22 deletions
@@ -0,0 +1,122 @@
// FE-COMP-COLFILTERBAR-001 to FE-COMP-COLFILTERBAR-008
import React from 'react';
import { render, screen, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { resetAllStores } from '../../../tests/helpers/store';
import { useTranslation } from '../../i18n/TranslationContext';
import type { CategoryOption } from '../../pages/collections/collectionsModel';
import type { StatusFilter } from '../../store/collectionStore';
import CollectionFilterBar from './CollectionFilterBar';
// The component takes `t` as a prop; pull the REAL translation fn from context so
// visible labels are actual English strings (All / Idea / Want to go / Visited / Select).
type HarnessProps = Omit<React.ComponentProps<typeof CollectionFilterBar>, 't'>;
function Harness(props: HarnessProps): React.ReactElement {
const { t } = useTranslation();
return <CollectionFilterBar {...props} t={t} />;
}
const CATEGORY_OPTIONS: CategoryOption[] = [
{ id: 1, name: 'Food', color: '#f00', icon: null, count: 2 },
];
function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
return {
statusFilter: 'all' as StatusFilter,
counts: { all: 3, idea: 1, want: 1, visited: 1 },
categoryFilter: 'all',
categoryOptions: CATEGORY_OPTIONS,
onStatusFilter: vi.fn(),
onCategoryFilter: vi.fn(),
showSelect: true,
selectMode: false,
onToggleSelect: vi.fn(),
...overrides,
};
}
beforeEach(() => {
resetAllStores();
});
describe('CollectionFilterBar', () => {
it('FE-COMP-COLFILTERBAR-001: renders the status dropdown showing the current "All" filter', () => {
render(<Harness {...makeProps()} />);
// Both dropdown triggers currently read "All" (status=all, category=all).
// With a category present there are exactly two "All" triggers: status + category.
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2);
});
it('FE-COMP-COLFILTERBAR-002: opening the status dropdown reveals the status options', async () => {
const user = userEvent.setup();
render(<Harness {...makeProps()} />);
// First "All" trigger is the status dropdown (rendered before the category one).
const statusTrigger = screen.getAllByRole('button', { name: 'All' })[0];
expect(statusTrigger).toHaveAttribute('aria-expanded', 'false');
await user.click(statusTrigger);
const listbox = screen.getByRole('listbox');
expect(within(listbox).getByRole('option', { name: /Idea/i })).toBeInTheDocument();
expect(within(listbox).getByRole('option', { name: /Want to go/i })).toBeInTheDocument();
expect(within(listbox).getByRole('option', { name: /Visited/i })).toBeInTheDocument();
});
it('FE-COMP-COLFILTERBAR-003: clicking a status option calls onStatusFilter with that status', async () => {
const user = userEvent.setup();
const onStatusFilter = vi.fn();
render(<Harness {...makeProps({ onStatusFilter })} />);
await user.click(screen.getAllByRole('button', { name: 'All' })[0]);
const listbox = screen.getByRole('listbox');
await user.click(within(listbox).getByRole('option', { name: /Want to go/i }));
expect(onStatusFilter).toHaveBeenCalledTimes(1);
expect(onStatusFilter).toHaveBeenCalledWith('want');
});
it('FE-COMP-COLFILTERBAR-004: the category dropdown is present when categoryOptions is non-empty', () => {
render(<Harness {...makeProps()} />);
// Two dropdown triggers = status + category.
const triggers = screen.getAllByRole('button', { name: 'All' });
expect(triggers).toHaveLength(2);
});
it('FE-COMP-COLFILTERBAR-005: the category dropdown is hidden when categoryOptions is empty', () => {
render(<Harness {...makeProps({ categoryOptions: [] })} />);
// Only the status dropdown remains.
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(1);
});
it('FE-COMP-COLFILTERBAR-006: clicking a category option calls onCategoryFilter with the category id', async () => {
const user = userEvent.setup();
const onCategoryFilter = vi.fn();
render(<Harness {...makeProps({ onCategoryFilter })} />);
// Second "All" trigger is the category dropdown.
await user.click(screen.getAllByRole('button', { name: 'All' })[1]);
const listbox = screen.getByRole('listbox');
await user.click(within(listbox).getByRole('option', { name: /Food/i }));
expect(onCategoryFilter).toHaveBeenCalledTimes(1);
expect(onCategoryFilter).toHaveBeenCalledWith(1);
});
it('FE-COMP-COLFILTERBAR-007: clicking the Select button calls onToggleSelect', async () => {
const user = userEvent.setup();
const onToggleSelect = vi.fn();
render(<Harness {...makeProps({ onToggleSelect })} />);
const selectBtn = screen.getByRole('button', { name: 'Select' });
expect(selectBtn).toHaveAttribute('aria-pressed', 'false');
await user.click(selectBtn);
expect(onToggleSelect).toHaveBeenCalledTimes(1);
});
it('FE-COMP-COLFILTERBAR-008: showSelect=false hides the Select button', () => {
render(<Harness {...makeProps({ showSelect: false })} />);
expect(screen.queryByRole('button', { name: 'Select' })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,159 @@
// FE-COMP-COLLIST-001 to FE-COMP-COLLIST-010
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionPlace } from '@trek/shared';
import { useAuthStore } from '../../store/authStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import { useTranslation } from '../../i18n/TranslationContext';
import CollectionList from './CollectionList';
// A saved-place row calls PlaceAvatar, which reads placesPhotosEnabled from the
// auth store. Seeding it false (and leaving image_url unset) keeps the avatar a
// plain category icon — no photoService fetch, no network in the test.
// Two inline CollectionPlace literals: one categorised (idea), one bare (want).
const cafe = {
id: 101,
collection_id: 1,
name: 'Blue Bottle Coffee',
address: '123 Market St, San Francisco',
status: 'idea',
category: { id: 5, name: 'Cafe', color: '#f59e0b', icon: 'Coffee' },
image_url: null,
} as unknown as CollectionPlace;
const bridge = {
id: 202,
collection_id: 1,
name: 'Golden Gate Bridge',
address: 'Golden Gate, San Francisco',
status: 'want',
image_url: null,
} as unknown as CollectionPlace;
const places = [cafe, bridge];
// Grab the real English translation fn so status labels match visible strings.
function TFnProbe({ onReady }: { onReady: (t: ReturnType<typeof useTranslation>['t']) => void }) {
const { t } = useTranslation();
onReady(t);
return null;
}
let t: ReturnType<typeof useTranslation>['t'];
render(<TFnProbe onReady={fn => { t = fn; }} />);
interface Handlers {
onOpenPlace: ReturnType<typeof vi.fn>;
onStatusChange: ReturnType<typeof vi.fn>;
onToggleSelect: ReturnType<typeof vi.fn>;
}
function renderList(over: Partial<{
selectMode: boolean;
selectedIds: number[];
selectedPlaceId: number | null;
onStatusChange: ((placeId: number, status: string) => void) | undefined;
}> = {}, handlers?: Handlers) {
const h = handlers ?? {
onOpenPlace: vi.fn(),
onStatusChange: vi.fn(),
onToggleSelect: vi.fn(),
};
const onStatusChange = 'onStatusChange' in over ? over.onStatusChange : h.onStatusChange;
render(
<CollectionList
places={places}
selectedPlaceId={over.selectedPlaceId ?? null}
selectMode={over.selectMode ?? false}
selectedIds={over.selectedIds ?? []}
onOpenPlace={h.onOpenPlace as (id: number) => void}
onStatusChange={onStatusChange as never}
onToggleSelect={h.onToggleSelect as (id: number) => void}
t={t}
/>,
);
return h;
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false });
});
describe('CollectionList', () => {
it('FE-COMP-COLLIST-001: renders both place names', () => {
renderList();
expect(screen.getByText('Blue Bottle Coffee')).toBeInTheDocument();
expect(screen.getByText('Golden Gate Bridge')).toBeInTheDocument();
});
it('FE-COMP-COLLIST-002: renders both place addresses', () => {
renderList();
expect(screen.getByText('123 Market St, San Francisco')).toBeInTheDocument();
expect(screen.getByText('Golden Gate, San Francisco')).toBeInTheDocument();
});
it('FE-COMP-COLLIST-003: renders the category name for the categorised place', () => {
renderList();
expect(screen.getByText('Cafe')).toBeInTheDocument();
});
it('FE-COMP-COLLIST-004: clicking a row calls onOpenPlace with the place id', async () => {
const user = userEvent.setup();
const h = renderList();
const row = screen.getByText('Blue Bottle Coffee').closest('.col-lrow') as HTMLElement;
await user.click(row);
expect(h.onOpenPlace).toHaveBeenCalledWith(101);
expect(h.onToggleSelect).not.toHaveBeenCalled();
});
it('FE-COMP-COLLIST-005: in select mode a row click calls onToggleSelect instead of onOpenPlace', async () => {
const user = userEvent.setup();
const h = renderList({ selectMode: true });
const row = screen.getByText('Golden Gate Bridge').closest('.col-lrow') as HTMLElement;
await user.click(row);
expect(h.onToggleSelect).toHaveBeenCalledWith(202);
expect(h.onOpenPlace).not.toHaveBeenCalled();
});
it('FE-COMP-COLLIST-006: the status badge is an interactive button when onStatusChange is provided', () => {
renderList();
// idea → label "Idea"; the badge is a role=button span with aria-label = label.
expect(screen.getByRole('button', { name: 'Idea' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Want to go' })).toBeInTheDocument();
});
it('FE-COMP-COLLIST-007: clicking the interactive badge cycles the status without opening the place', async () => {
const user = userEvent.setup();
const h = renderList();
await user.click(screen.getByRole('button', { name: 'Idea' }));
// idea → want, and the badge stops propagation so the row does not open.
expect(h.onStatusChange).toHaveBeenCalledWith(101, 'want');
expect(h.onOpenPlace).not.toHaveBeenCalled();
});
it('FE-COMP-COLLIST-008: the status badge is read-only (not a button) when onStatusChange is undefined', () => {
renderList({ onStatusChange: undefined });
// Label text still renders...
expect(screen.getByText('Idea')).toBeInTheDocument();
// ...but there is no interactive status button for it.
expect(screen.queryByRole('button', { name: 'Idea' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Want to go' })).not.toBeInTheDocument();
});
it('FE-COMP-COLLIST-009: in select mode the status badge is read-only even with onStatusChange provided', () => {
renderList({ selectMode: true });
expect(screen.getByText('Idea')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Idea' })).not.toBeInTheDocument();
});
it('FE-COMP-COLLIST-010: renders one clickable row per place', () => {
renderList();
// Each place contributes a role=button row; badges add their own buttons too,
// but every place name must sit inside a .col-lrow row element.
expect(screen.getByText('Blue Bottle Coffee').closest('.col-lrow')).toBeInTheDocument();
expect(screen.getByText('Golden Gate Bridge').closest('.col-lrow')).toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import React from 'react'
import { PanelLeftClose, PanelLeftOpen, Search, CheckSquare, Plus } from 'lucide-react'
import { PanelLeftClose, PanelLeftOpen, Search, Plus } from 'lucide-react'
import type { CollectionPlace } from '@trek/shared'
import type { TranslationFn } from '../../types'
import CollectionMap from './CollectionMap'
@@ -15,10 +15,6 @@ interface CollectionMapPanelProps {
/** 'list' = split (map can be expanded); 'map' = full (list collapsed). */
view: 'list' | 'map'
onToggleView: () => void
/** Bulk-select toggle — hidden for the "All saved" view. */
showSelect: boolean
selectMode: boolean
onToggleSelect: () => void
/** Show a "+" to add a place to the current list (real lists only). */
canAddPlace: boolean
onAddPlace: () => void
@@ -34,7 +30,7 @@ interface CollectionMapPanelProps {
*/
export default function CollectionMapPanel({
places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView,
showSelect, selectMode, onToggleSelect, canAddPlace, onAddPlace, search, onSearch, t,
canAddPlace, onAddPlace, search, onSearch, t,
}: CollectionMapPanelProps): React.ReactElement {
return (
<div className="col-map-shell">
@@ -57,18 +53,6 @@ export default function CollectionMapPanel({
>
{view === 'map' ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
</button>
{showSelect && (
<button
type="button"
onClick={onToggleSelect}
disabled={view === 'map'}
className={`col-map-btn${selectMode ? ' on' : ''}`}
aria-label={t('collections.selectMode')}
title={t('collections.selectMode')}
>
<CheckSquare size={16} />
</button>
)}
</div>
<div className="col-map-group right">
{canAddPlace && (
@@ -0,0 +1,141 @@
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-010
import React from 'react';
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import type { CollectionPlace } from '@trek/shared';
import { useTranslation } from '../../i18n/TranslationContext';
import CollectionPlaceDetail from './CollectionPlaceDetail';
// The component takes `t` as a PROP (not from context), so wrap it in a tiny
// consumer that feeds it the real English `t` from the TranslationProvider the
// test render helper mounts. That way we can assert on visible English strings.
type DetailProps = React.ComponentProps<typeof CollectionPlaceDetail>;
function TranslatedDetail(props: Omit<DetailProps, 't'>): React.ReactElement {
const { t } = useTranslation();
return <CollectionPlaceDetail {...props} t={t} />;
}
// Place literal per spec: no image_url / lat / lng / provider id, so the cover
// stays a gradient and status is 'idea'.
const place: CollectionPlace = {
id: 1,
collection_id: 10,
name: 'Test Cafe',
status: 'idea',
description: 'Nice spot',
address: 'Somewhere',
links: [{ url: 'https://x.com' }],
category: { id: 1, name: 'Food', color: '#f00', icon: null },
};
function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
const props = {
place,
canEdit: true,
canDelete: true,
categories: [],
anchorRect: null,
onClose: vi.fn(),
onSetStatus: vi.fn(),
onSave: vi.fn().mockResolvedValue(undefined),
onCopyToTrip: vi.fn(),
onRemove: vi.fn(),
...overrides,
} as Omit<DetailProps, 't'>;
render(<TranslatedDetail {...props} />);
return props;
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false });
// The detail sheet asks the maps provider for a cover photo on mount when a
// place carries no image of its own — stub it so nothing hits the network.
server.use(
http.get('/api/maps/place-photo/:id', () =>
HttpResponse.json({ photoUrl: null, attribution: null }),
),
);
});
describe('CollectionPlaceDetail', () => {
it('FE-COMP-COLDETAIL-001: renders the place name, address and description', async () => {
renderDetail();
expect(await screen.findByRole('heading', { name: 'Test Cafe' })).toBeInTheDocument();
expect(screen.getByText('Somewhere')).toBeInTheDocument();
expect(screen.getByText('Nice spot')).toBeInTheDocument();
});
// ── Editor / admin (canEdit + canDelete) ────────────────────────────────────
it('FE-COMP-COLDETAIL-002: shows Edit and Remove buttons when canEdit && canDelete', async () => {
renderDetail({ canEdit: true, canDelete: true });
expect(await screen.findByRole('button', { name: 'Edit' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove from list' })).toBeInTheDocument();
});
it('FE-COMP-COLDETAIL-003: clicking a status option calls onSetStatus when canEdit', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: true, canDelete: true });
const visited = await screen.findByRole('button', { name: 'Visited' });
await user.click(visited);
expect(props.onSetStatus).toHaveBeenCalledTimes(1);
expect(props.onSetStatus).toHaveBeenCalledWith('visited');
});
it('FE-COMP-COLDETAIL-004: current status option is pressed, others are not', async () => {
renderDetail({ canEdit: true, canDelete: true });
expect(await screen.findByRole('button', { name: 'Idea' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Want to go' })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-COMP-COLDETAIL-005: entering edit mode reveals name input + Save', async () => {
const user = userEvent.setup();
renderDetail({ canEdit: true });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
expect(screen.getByDisplayValue('Test Cafe')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument();
});
// ── Viewer (no edit / no delete) ────────────────────────────────────────────
it('FE-COMP-COLDETAIL-006: hides Edit and Remove buttons when canEdit=false && canDelete=false', async () => {
renderDetail({ canEdit: false, canDelete: false });
// Wait for the async photo effect to settle before asserting absence.
expect(await screen.findByRole('button', { name: 'Copy to trip' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Remove from list' })).not.toBeInTheDocument();
});
it('FE-COMP-COLDETAIL-007: clicking a status option does NOT call onSetStatus when canEdit=false', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: false, canDelete: false });
const visited = await screen.findByRole('button', { name: 'Visited' });
await user.click(visited);
expect(props.onSetStatus).not.toHaveBeenCalled();
});
it('FE-COMP-COLDETAIL-008: status segment still renders (read-only) for viewers', async () => {
renderDetail({ canEdit: false, canDelete: false });
expect(await screen.findByRole('button', { name: 'Idea' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Visited' })).toBeInTheDocument();
});
// ── Copy-to-trip is available in read mode regardless of permissions ─────────
it('FE-COMP-COLDETAIL-009: Copy to trip button fires onCopyToTrip (editor)', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: true, canDelete: true });
await user.click(await screen.findByRole('button', { name: 'Copy to trip' }));
expect(props.onCopyToTrip).toHaveBeenCalledTimes(1);
});
it('FE-COMP-COLDETAIL-010: Copy to trip button fires onCopyToTrip (viewer)', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: false, canDelete: false });
await user.click(await screen.findByRole('button', { name: 'Copy to trip' }));
expect(props.onCopyToTrip).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,108 @@
// FE-COMP-MOVETOLIST-001 to FE-COMP-MOVETOLIST-009
import { render, screen, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { Collection } from '@trek/shared';
import { useTranslation } from '../../i18n/TranslationContext';
import MoveToListModal from './MoveToListModal';
// The modal receives `t` as a prop rather than reading context, so a tiny harness
// pulls the REAL English translator from the provider and forwards it. That keeps
// the assertions against real strings ("Move 2 to another list") instead of keys.
function Harness(props: Omit<React.ComponentProps<typeof MoveToListModal>, 't'>) {
const { t } = useTranslation();
return <MoveToListModal {...props} t={t} />;
}
const listA: Collection = { id: 11, owner_id: 1, name: 'Weekend in Rome', color: '#ef4444', place_count: 3 };
const listB: Collection = { id: 22, owner_id: 1, name: 'Tokyo Food Tour', color: '#22c55e', place_count: 7 };
function renderModal(overrides: Partial<React.ComponentProps<typeof MoveToListModal>> = {}) {
const props = {
mode: 'move' as const,
lists: [listA, listB],
count: 2,
onPick: vi.fn().mockResolvedValue(undefined),
onClose: vi.fn(),
...overrides,
};
render(<Harness {...props} />);
return props;
}
describe('MoveToListModal', () => {
it('FE-COMP-MOVETOLIST-001: renders every candidate list name', () => {
renderModal();
expect(screen.getByText('Weekend in Rome')).toBeInTheDocument();
expect(screen.getByText('Tokyo Food Tour')).toBeInTheDocument();
});
it('FE-COMP-MOVETOLIST-002: title reflects the count in move mode', () => {
renderModal({ mode: 'move', count: 2 });
// collections.moveToListTitle = 'Move {count} to another list'
expect(screen.getByRole('heading', { name: 'Move 2 to another list' })).toBeInTheDocument();
});
it('FE-COMP-MOVETOLIST-003: title reflects the count in copy mode', () => {
renderModal({ mode: 'copy', count: 5 });
// collections.duplicateToListTitle = 'Duplicate {count} to another list'
expect(screen.getByRole('heading', { name: 'Duplicate 5 to another list' })).toBeInTheDocument();
});
it('FE-COMP-MOVETOLIST-004: shows the place count subtitle per list', () => {
renderModal();
// collections.placeCount = '{count} places'
expect(screen.getByText('3 places')).toBeInTheDocument();
expect(screen.getByText('7 places')).toBeInTheDocument();
});
it('FE-COMP-MOVETOLIST-005: clicking a row calls onPick with that list id', async () => {
const user = userEvent.setup();
const { onPick } = renderModal();
await user.click(screen.getByRole('button', { name: /Tokyo Food Tour/i }));
expect(onPick).toHaveBeenCalledTimes(1);
expect(onPick).toHaveBeenCalledWith(22);
});
it('FE-COMP-MOVETOLIST-006: empty lists shows the "no other lists" message and renders no rows', () => {
renderModal({ lists: [] });
// collections.noOtherLists = 'No other lists yet'
expect(screen.getByText('No other lists yet')).toBeInTheDocument();
// No selectable list rows exist (only the modal close button remains).
expect(screen.queryByRole('button', { name: /Weekend in Rome|Tokyo Food Tour|places/i })).not.toBeInTheDocument();
});
it('FE-COMP-MOVETOLIST-007: move mode renders a trailing arrow icon on each row', () => {
renderModal({ mode: 'move' });
const row = screen.getByRole('button', { name: /Weekend in Rome/i });
expect(row.querySelector('.lucide-arrow-right')).toBeTruthy();
expect(row.querySelector('.lucide-copy')).toBeFalsy();
});
it('FE-COMP-MOVETOLIST-008: copy mode renders a trailing copy icon on each row', () => {
renderModal({ mode: 'copy' });
const row = screen.getByRole('button', { name: /Weekend in Rome/i });
expect(row.querySelector('.lucide-copy')).toBeTruthy();
expect(row.querySelector('.lucide-arrow-right')).toBeFalsy();
});
it('FE-COMP-MOVETOLIST-009: a second click while the first pick is pending is ignored', async () => {
const user = userEvent.setup();
// Never resolves, so the modal stays "busy" after the first click.
const onPick = vi.fn().mockReturnValue(new Promise<void>(() => {}));
renderModal({ onPick });
const rome = screen.getByRole('button', { name: /Weekend in Rome/i });
const tokyo = screen.getByRole('button', { name: /Tokyo Food Tour/i });
await user.click(rome);
// Rows disable while busy; a click on another row must not fire a second pick.
await user.click(tokyo);
expect(onPick).toHaveBeenCalledTimes(1);
expect(onPick).toHaveBeenCalledWith(11);
// Confirm the busy state actually disabled the rows.
expect(within(tokyo).queryByText('Tokyo Food Tour')).toBeInTheDocument();
expect(tokyo).toBeDisabled();
});
});
@@ -0,0 +1,126 @@
// FE-COMP-STATUSBADGE-001 to FE-COMP-STATUSBADGE-013
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionStatus } from '@trek/shared';
import { resetAllStores } from '../../../tests/helpers/store';
import { useTranslation } from '../../i18n/TranslationContext';
import StatusBadge from './StatusBadge';
// StatusBadge takes a `t` prop (TranslationFn) rather than reading the context
// itself, so this harness pulls the real English `t` out of the provider that
// the render helper wraps around us and forwards it. That way assertions run
// against real translated strings, not i18n keys.
type BadgeProps = {
status: CollectionStatus;
onChange?: (next: CollectionStatus) => void;
showLabel?: boolean;
onCover?: boolean;
};
function Badge(props: BadgeProps) {
const { t } = useTranslation();
return <StatusBadge {...props} t={t} />;
}
beforeEach(() => {
resetAllStores();
});
describe('StatusBadge', () => {
// ── Current-status label ─────────────────────────────────────────────────────
it('FE-COMP-STATUSBADGE-001: renders the "Idea" label for the idea status', () => {
render(<Badge status="idea" />);
expect(screen.getByText('Idea')).toBeInTheDocument();
});
it('FE-COMP-STATUSBADGE-002: renders the "Want to go" label for the want status', () => {
render(<Badge status="want" />);
expect(screen.getByText('Want to go')).toBeInTheDocument();
});
it('FE-COMP-STATUSBADGE-003: renders the "Visited" label for the visited status', () => {
render(<Badge status="visited" />);
expect(screen.getByText('Visited')).toBeInTheDocument();
});
it('FE-COMP-STATUSBADGE-004: hides the label when showLabel is false', () => {
render(<Badge status="idea" showLabel={false} />);
expect(screen.queryByText('Idea')).not.toBeInTheDocument();
});
// ── One-tap cycle: idea → want → visited → idea ──────────────────────────────
it('FE-COMP-STATUSBADGE-005: exposes a button role when onChange is supplied', () => {
render(<Badge status="idea" onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Idea' })).toBeInTheDocument();
});
it('FE-COMP-STATUSBADGE-006: clicking an idea badge calls onChange with "want"', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<Badge status="idea" onChange={onChange} />);
await user.click(screen.getByRole('button', { name: 'Idea' }));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('want');
});
it('FE-COMP-STATUSBADGE-007: clicking a want badge calls onChange with "visited"', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<Badge status="want" onChange={onChange} />);
await user.click(screen.getByRole('button', { name: 'Want to go' }));
expect(onChange).toHaveBeenCalledWith('visited');
});
it('FE-COMP-STATUSBADGE-008: clicking a visited badge wraps around to "idea"', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<Badge status="visited" onChange={onChange} />);
await user.click(screen.getByRole('button', { name: 'Visited' }));
expect(onChange).toHaveBeenCalledWith('idea');
});
it('FE-COMP-STATUSBADGE-009: pressing Enter cycles the status', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<Badge status="idea" onChange={onChange} />);
const badge = screen.getByRole('button', { name: 'Idea' });
badge.focus();
await user.keyboard('{Enter}');
expect(onChange).toHaveBeenCalledWith('want');
});
it('FE-COMP-STATUSBADGE-010: interactive badge advertises the cycle hint in its title', () => {
render(<Badge status="idea" onChange={vi.fn()} />);
// English: 'collections.status.cycleHint' = 'tap to change'
expect(screen.getByRole('button', { name: 'Idea' })).toHaveAttribute(
'title',
'Idea — tap to change',
);
});
// ── Read-only (onChange omitted) ─────────────────────────────────────────────
it('FE-COMP-STATUSBADGE-011: renders as a static badge with no button role when onChange is omitted', () => {
render(<Badge status="want" />);
expect(screen.getByText('Want to go')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('FE-COMP-STATUSBADGE-012: read-only badge uses the plain label as its title (no cycle hint)', () => {
render(<Badge status="visited" />);
const label = screen.getByText('Visited');
const pill = label.parentElement as HTMLElement;
expect(pill).toHaveAttribute('title', 'Visited');
expect(pill.getAttribute('title')).not.toMatch(/tap to change/i);
});
it('FE-COMP-STATUSBADGE-013: clicking a read-only badge does not throw', async () => {
const user = userEvent.setup();
render(<Badge status="idea" />);
// No interactive handler wired up — the click must be a harmless no-op.
await user.click(screen.getByText('Idea'));
expect(screen.getByText('Idea')).toBeInTheDocument();
});
});
+1 -4
View File
@@ -40,7 +40,7 @@ export default function CollectionsPage(): React.ReactElement {
const hasPlaces = c.places.length > 0
const noLists = !c.loading && c.collections.length === 0
const showSelect = !c.isAllSaved && c.activeCollection != null
const showSelect = c.isAllSaved || c.activeCollection != null
// Selecting a place toggles it, so clicking it again — or the map background —
// clears it. Below the desktop breakpoint the list and map are separate views;
@@ -85,9 +85,6 @@ export default function CollectionsPage(): React.ReactElement {
overlay={overlay}
view={c.view}
onToggleView={toggleView}
showSelect={showSelect}
selectMode={c.selectMode}
onToggleSelect={() => c.setSelectMode(!c.selectMode)}
canAddPlace={canAddPlace}
onAddPlace={() => c.setShowAddPlace(true)}
search={c.search}
@@ -0,0 +1,322 @@
// FE-COLLECTIONS-MODEL-001 to FE-COLLECTIONS-MODEL-030
// Pure-function tests for the Collections page data-shaping helpers. No React,
// no network — plain vitest over collectionsModel.ts.
import type { CollectionPlace, CollectionStatus } from '@trek/shared';
import {
filterPlaces,
sortPlaces,
statusCounts,
presentCategories,
mappablePlaces,
normalizeLinkUrl,
} from './collectionsModel';
// ── Inline CollectionPlace-ish builder ────────────────────────────────────────
// Only the fields the helpers actually read are meaningful; the rest satisfy the
// type. Callers override what a given case cares about.
interface CatLike {
id: number;
name: string | null;
color?: string | null;
icon?: string | null;
}
interface PlaceLike {
id: number;
name: string;
status: CollectionStatus;
category_id?: number | null;
category?: CatLike | null;
lat?: number | null;
lng?: number | null;
address?: string | null;
notes?: string | null;
sort_order?: number;
created_at?: string;
}
function cp(overrides: PlaceLike): CollectionPlace {
return {
collection_id: 1,
...overrides,
} as unknown as CollectionPlace;
}
// A stray hole in the array (WS race / partial payload). Typed as CollectionPlace
// so it can sit in the arrays the helpers receive.
const HOLE = undefined as unknown as CollectionPlace;
describe('collectionsModel', () => {
// ── filterPlaces ────────────────────────────────────────────────────────────
describe('filterPlaces', () => {
it('FE-COLLECTIONS-MODEL-001: status filter keeps only the matching status', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea' }),
cp({ id: 2, name: 'B', status: 'want' }),
cp({ id: 3, name: 'C', status: 'want' }),
cp({ id: 4, name: 'D', status: 'visited' }),
];
const out = filterPlaces(places, 'want', '');
expect(out.map(p => p.id)).toEqual([2, 3]);
});
it("FE-COLLECTIONS-MODEL-002: statusFilter 'all' keeps every (defined) place", () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea' }),
cp({ id: 2, name: 'B', status: 'want' }),
cp({ id: 3, name: 'C', status: 'visited' }),
];
expect(filterPlaces(places, 'all', '')).toHaveLength(3);
});
it('FE-COLLECTIONS-MODEL-003: category filter keeps only that category_id', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea', category_id: 5 }),
cp({ id: 2, name: 'B', status: 'idea', category_id: 9 }),
cp({ id: 3, name: 'C', status: 'idea', category_id: 5 }),
cp({ id: 4, name: 'D', status: 'idea' }), // no category
];
const out = filterPlaces(places, 'all', '', 5);
expect(out.map(p => p.id)).toEqual([1, 3]);
});
it('FE-COLLECTIONS-MODEL-004: search matches place name (case-insensitive)', () => {
const places = [
cp({ id: 1, name: 'Eiffel Tower', status: 'idea' }),
cp({ id: 2, name: 'Louvre', status: 'idea' }),
];
const out = filterPlaces(places, 'all', 'eiffel');
expect(out.map(p => p.id)).toEqual([1]);
});
it('FE-COLLECTIONS-MODEL-005: search matches address', () => {
const places = [
cp({ id: 1, name: 'Nameless', status: 'idea', address: '10 Rue de Paris' }),
cp({ id: 2, name: 'Other', status: 'idea', address: 'Berlin' }),
];
const out = filterPlaces(places, 'all', 'rue');
expect(out.map(p => p.id)).toEqual([1]);
});
it('FE-COLLECTIONS-MODEL-006: search matches notes', () => {
const places = [
cp({ id: 1, name: 'X', status: 'idea', notes: 'sunset spot' }),
cp({ id: 2, name: 'Y', status: 'idea', notes: 'breakfast' }),
];
const out = filterPlaces(places, 'all', 'SUNSET');
expect(out.map(p => p.id)).toEqual([1]);
});
it('FE-COLLECTIONS-MODEL-007: blank/whitespace search returns all', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea' }),
cp({ id: 2, name: 'B', status: 'want' }),
];
expect(filterPlaces(places, 'all', ' ')).toHaveLength(2);
});
it('FE-COLLECTIONS-MODEL-008: a stray undefined entry is skipped, not crashing', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea' }),
HOLE,
cp({ id: 2, name: 'B', status: 'want' }),
];
expect(() => filterPlaces(places, 'all', '')).not.toThrow();
const out = filterPlaces(places, 'all', '');
expect(out.map(p => p.id)).toEqual([1, 2]);
});
it('FE-COLLECTIONS-MODEL-009: status + category + search combine (AND)', () => {
const places = [
cp({ id: 1, name: 'Beach Bar', status: 'want', category_id: 3, notes: 'cocktails' }),
cp({ id: 2, name: 'Beach Hut', status: 'idea', category_id: 3 }),
cp({ id: 3, name: 'Beach Cafe', status: 'want', category_id: 9 }),
];
const out = filterPlaces(places, 'want', 'beach', 3);
expect(out.map(p => p.id)).toEqual([1]);
});
});
// ── sortPlaces ──────────────────────────────────────────────────────────────
describe('sortPlaces', () => {
it('FE-COLLECTIONS-MODEL-010: orders by sort_order ascending', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea', sort_order: 2 }),
cp({ id: 2, name: 'B', status: 'idea', sort_order: 0 }),
cp({ id: 3, name: 'C', status: 'idea', sort_order: 1 }),
];
expect(sortPlaces(places).map(p => p.id)).toEqual([2, 3, 1]);
});
it('FE-COLLECTIONS-MODEL-011: ties on sort_order fall back to created_at newest-first', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea', sort_order: 0, created_at: '2025-01-01T00:00:00Z' }),
cp({ id: 2, name: 'B', status: 'idea', sort_order: 0, created_at: '2025-03-01T00:00:00Z' }),
cp({ id: 3, name: 'C', status: 'idea', sort_order: 0, created_at: '2025-02-01T00:00:00Z' }),
];
// newest created_at first
expect(sortPlaces(places).map(p => p.id)).toEqual([2, 3, 1]);
});
it('FE-COLLECTIONS-MODEL-012: missing sort_order is treated as 0', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea', sort_order: 5 }),
cp({ id: 2, name: 'B', status: 'idea' }), // no sort_order -> 0
];
expect(sortPlaces(places).map(p => p.id)).toEqual([2, 1]);
});
it('FE-COLLECTIONS-MODEL-013: does not mutate the input array', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea', sort_order: 2 }),
cp({ id: 2, name: 'B', status: 'idea', sort_order: 1 }),
];
const snapshot = places.map(p => p.id);
sortPlaces(places);
expect(places.map(p => p.id)).toEqual(snapshot);
});
});
// ── statusCounts ────────────────────────────────────────────────────────────
describe('statusCounts', () => {
it('FE-COLLECTIONS-MODEL-014: counts per status plus a total under all', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea' }),
cp({ id: 2, name: 'B', status: 'idea' }),
cp({ id: 3, name: 'C', status: 'want' }),
cp({ id: 4, name: 'D', status: 'visited' }),
cp({ id: 5, name: 'E', status: 'visited' }),
cp({ id: 6, name: 'F', status: 'visited' }),
];
expect(statusCounts(places)).toEqual({ all: 6, idea: 2, want: 1, visited: 3 });
});
it('FE-COLLECTIONS-MODEL-015: empty input yields all zeros', () => {
expect(statusCounts([])).toEqual({ all: 0, idea: 0, want: 0, visited: 0 });
});
it('FE-COLLECTIONS-MODEL-016: undefined entries are skipped from every count', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea' }),
HOLE,
cp({ id: 2, name: 'B', status: 'want' }),
];
expect(() => statusCounts(places)).not.toThrow();
expect(statusCounts(places)).toEqual({ all: 2, idea: 1, want: 1, visited: 0 });
});
});
// ── presentCategories ───────────────────────────────────────────────────────
describe('presentCategories', () => {
it('FE-COLLECTIONS-MODEL-017: distinct categories with per-category counts', () => {
const museum = { id: 3, name: 'Museums', color: '#111', icon: 'landmark' };
const food = { id: 7, name: 'Food', color: '#222', icon: 'utensils' };
const places = [
cp({ id: 1, name: 'A', status: 'idea', category_id: 3, category: museum }),
cp({ id: 2, name: 'B', status: 'idea', category_id: 3, category: museum }),
cp({ id: 3, name: 'C', status: 'idea', category_id: 7, category: food }),
];
const out = presentCategories(places);
// sorted alphabetically by name: Food, Museums
expect(out.map(c => c.name)).toEqual(['Food', 'Museums']);
expect(out.find(c => c.id === 3)?.count).toBe(2);
expect(out.find(c => c.id === 7)?.count).toBe(1);
});
it('FE-COLLECTIONS-MODEL-018: carries color and icon from the category', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea', category_id: 4, category: { id: 4, name: 'Parks', color: '#0f0', icon: 'tree' } }),
];
const [opt] = presentCategories(places);
expect(opt).toMatchObject({ id: 4, name: 'Parks', color: '#0f0', icon: 'tree', count: 1 });
});
it('FE-COLLECTIONS-MODEL-019: does NOT throw on an undefined entry (white-screen guard)', () => {
const places = [
HOLE,
cp({ id: 1, name: 'A', status: 'idea', category_id: 3, category: { id: 3, name: 'Museums', color: null, icon: null } }),
];
let out: ReturnType<typeof presentCategories> = [];
expect(() => { out = presentCategories(places); }).not.toThrow();
expect(out.map(c => c.id)).toEqual([3]);
});
it('FE-COLLECTIONS-MODEL-020: place with category_id but no category object is skipped (does not throw)', () => {
// Regression: presentCategories used to read undefined.category_id / .name here.
const places = [
cp({ id: 1, name: 'A', status: 'idea', category_id: 3 }), // category_id but no category
cp({ id: 2, name: 'B', status: 'idea', category_id: 3, category: { id: 3, name: 'Museums', color: null, icon: null } }),
];
let out: ReturnType<typeof presentCategories> = [];
expect(() => { out = presentCategories(places); }).not.toThrow();
// Only the place that actually carries the joined category contributes.
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ id: 3, name: 'Museums', count: 1 });
});
it('FE-COLLECTIONS-MODEL-021: places with no category at all yield an empty list', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea' }),
cp({ id: 2, name: 'B', status: 'want' }),
];
expect(presentCategories(places)).toEqual([]);
});
it('FE-COLLECTIONS-MODEL-022: null color/icon on the category survive as null', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea', category_id: 8, category: { id: 8, name: 'Misc', color: null, icon: null } }),
];
expect(presentCategories(places)[0]).toMatchObject({ id: 8, name: 'Misc', color: null, icon: null, count: 1 });
});
});
// ── mappablePlaces ──────────────────────────────────────────────────────────
describe('mappablePlaces', () => {
it('FE-COLLECTIONS-MODEL-023: keeps only places with numeric lat and lng', () => {
const places = [
cp({ id: 1, name: 'A', status: 'idea', lat: 48.85, lng: 2.35 }),
cp({ id: 2, name: 'B', status: 'idea', lat: null, lng: 2.35 }),
cp({ id: 3, name: 'C', status: 'idea', lat: 40.0, lng: null }),
cp({ id: 4, name: 'D', status: 'idea' }), // neither
];
expect(mappablePlaces(places).map(p => p.id)).toEqual([1]);
});
it('FE-COLLECTIONS-MODEL-024: keeps a place at the (0,0) origin (0 is numeric)', () => {
const places = [cp({ id: 1, name: 'NullIsland', status: 'idea', lat: 0, lng: 0 })];
expect(mappablePlaces(places).map(p => p.id)).toEqual([1]);
});
it('FE-COLLECTIONS-MODEL-025: skips a stray undefined entry without throwing', () => {
const places = [
HOLE,
cp({ id: 1, name: 'A', status: 'idea', lat: 10, lng: 20 }),
];
expect(() => mappablePlaces(places)).not.toThrow();
expect(mappablePlaces(places).map(p => p.id)).toEqual([1]);
});
});
// ── normalizeLinkUrl ────────────────────────────────────────────────────────
describe('normalizeLinkUrl', () => {
it('FE-COLLECTIONS-MODEL-026: prepends https:// to a scheme-less host', () => {
expect(normalizeLinkUrl('booking.com')).toBe('https://booking.com');
});
it('FE-COLLECTIONS-MODEL-027: leaves an http:// URL unchanged', () => {
expect(normalizeLinkUrl('http://example.com/path')).toBe('http://example.com/path');
});
it('FE-COLLECTIONS-MODEL-028: leaves an https:// URL unchanged (case-insensitive scheme)', () => {
expect(normalizeLinkUrl('https://example.com')).toBe('https://example.com');
expect(normalizeLinkUrl('HTTPS://example.com')).toBe('HTTPS://example.com');
});
it('FE-COLLECTIONS-MODEL-029: blank / whitespace-only input returns an empty string', () => {
expect(normalizeLinkUrl('')).toBe('');
expect(normalizeLinkUrl(' ')).toBe('');
});
it('FE-COLLECTIONS-MODEL-030: trims and strips leading slashes before prefixing', () => {
expect(normalizeLinkUrl(' booking.com ')).toBe('https://booking.com');
expect(normalizeLinkUrl('//booking.com')).toBe('https://booking.com');
});
});
});