mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
a1d6348c5f
Takes back the editable price, currency, website and phone on collection places from0df82185band its review follow-up ina910c2a83. Costs are kept inside a trip only; a collection does not carry them, and #2471 is a feature request rather than a bug. The collection files are back to how they were before, including the dimmed save button fromf16698bdb, which only mattered for the price field.
691 lines
33 KiB
TypeScript
691 lines
33 KiB
TypeScript
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-048
|
|
import React from 'react';
|
|
import { render, screen, fireEvent, waitFor, within } 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 { CollectionLabel, CollectionPlace } from '@trek/shared';
|
|
import type { Category } from '../../types';
|
|
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 },
|
|
};
|
|
|
|
const CATEGORIES: Category[] = [
|
|
{ id: 1, name: 'Food', color: '#f00', icon: 'utensils' },
|
|
{ id: 2, name: 'Museums', color: '#00f', icon: 'landmark' },
|
|
];
|
|
|
|
const LABELS: CollectionLabel[] = [
|
|
{ id: 1, collection_id: 10, name: 'Berlin', color: '#0ea5e9' },
|
|
{ id: 2, collection_id: 10, name: 'Nightlife', color: null },
|
|
];
|
|
|
|
let addToast: ReturnType<typeof vi.fn>;
|
|
|
|
function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
|
|
const props = {
|
|
place,
|
|
canEdit: true,
|
|
canDelete: true,
|
|
categories: [],
|
|
labels: [],
|
|
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 });
|
|
addToast = vi.fn();
|
|
window.__addToast = addToast as unknown as typeof window.__addToast;
|
|
// 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 }),
|
|
),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete window.__addToast;
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
// ── Custom cover image (#1136) ──────────────────────────────────────────────
|
|
it('FE-COMP-COLDETAIL-011: shows the cover upload control when canEdit && onUploadImage', async () => {
|
|
const user = userEvent.setup();
|
|
renderDetail({ canEdit: true, onUploadImage: vi.fn() });
|
|
const camera = await screen.findByRole('button', { name: 'Upload image' });
|
|
expect(camera).toBeInTheDocument();
|
|
|
|
// The camera button proxies the hidden file input.
|
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
const clicked = vi.fn();
|
|
input.addEventListener('click', clicked);
|
|
await user.click(camera);
|
|
expect(clicked).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-012: hides the cover upload control when onUploadImage is not provided', async () => {
|
|
renderDetail({ canEdit: true });
|
|
// Wait for the mount photo effect to settle before asserting absence.
|
|
expect(await screen.findByRole('button', { name: 'Copy to trip' })).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'Upload image' })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-013: removing a custom cover calls onSave with { image_url: null }', async () => {
|
|
const user = userEvent.setup();
|
|
const withImage: CollectionPlace = { ...place, image_url: '/uploads/places/mock.jpg' };
|
|
const props = renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: withImage });
|
|
await user.click(await screen.findByRole('button', { name: 'Remove image' }));
|
|
expect(props.onSave).toHaveBeenCalledWith({ image_url: null });
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-014: a failing cover removal surfaces the upload error', async () => {
|
|
const user = userEvent.setup();
|
|
const withImage: CollectionPlace = { ...place, image_url: '/uploads/places/mock.jpg' };
|
|
const onSave = vi.fn(() => Promise.reject({ response: { data: { error: 'Disk full' } } }));
|
|
renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: withImage, onSave });
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Remove image' }));
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Disk full', 'error', undefined));
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-015: picking a cover file hands the normalized file to onUploadImage', async () => {
|
|
const onUploadImage = vi.fn(async (_file: File) => {});
|
|
renderDetail({ canEdit: true, onUploadImage });
|
|
await screen.findByRole('button', { name: 'Upload image' });
|
|
|
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
const file = new File(['x'], 'cover.png', { type: 'image/png' });
|
|
fireEvent.change(input, { target: { files: [file] } });
|
|
|
|
await waitFor(() => expect(onUploadImage).toHaveBeenCalledTimes(1));
|
|
expect(onUploadImage.mock.calls[0][0]).toBe(file);
|
|
// The picker resets so re-selecting the same file fires again.
|
|
expect(input.value).toBe('');
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-016: a cancelled file picker uploads nothing', async () => {
|
|
const onUploadImage = vi.fn(async () => {});
|
|
renderDetail({ canEdit: true, onUploadImage });
|
|
await screen.findByRole('button', { name: 'Upload image' });
|
|
|
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { files: [] } });
|
|
expect(onUploadImage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-017: a failing upload surfaces the error and clears the busy state', async () => {
|
|
const onUploadImage = vi.fn(() => Promise.reject(new Error('Upload rejected')));
|
|
renderDetail({ canEdit: true, onUploadImage });
|
|
await screen.findByRole('button', { name: 'Upload image' });
|
|
|
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { files: [new File(['x'], 'cover.png', { type: 'image/png' })] } });
|
|
|
|
// A rejection without a server payload falls back to the localized message.
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not upload image', 'error', undefined));
|
|
// The spinner is gone again, so the control accepts a retry.
|
|
await waitFor(() => expect(document.querySelector('.animate-spin')).toBeNull());
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-018: the cover renders the fetched provider photo when the place has none', async () => {
|
|
server.use(
|
|
http.get('/api/maps/place-photo/:id', () =>
|
|
HttpResponse.json({ photoUrl: 'https://cdn.example/photo.jpg', attribution: null }),
|
|
),
|
|
);
|
|
renderDetail({ place: { ...place, google_place_id: 'gp-1' } });
|
|
|
|
await waitFor(() =>
|
|
expect(document.querySelector('.col-detail-cover img')).toHaveAttribute('src', 'https://cdn.example/photo.jpg'),
|
|
);
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-018b: a place with its own image never asks the provider for a photo', async () => {
|
|
const photo = vi.fn();
|
|
server.use(http.get('/api/maps/place-photo/:id', () => { photo(); return HttpResponse.json({ photoUrl: null }); }));
|
|
renderDetail({ place: { ...place, image_url: '/uploads/places/mine.jpg', google_place_id: 'gp-1' } });
|
|
|
|
await screen.findByRole('heading', { name: 'Test Cafe' });
|
|
expect(document.querySelector('.col-detail-cover img')).toHaveAttribute('src', '/uploads/places/mine.jpg');
|
|
expect(photo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-019: the category chip and the assigned label chips render in read mode', async () => {
|
|
renderDetail({ labels: LABELS, place: { ...place, label_ids: [1] } });
|
|
expect(await screen.findByText('Food')).toBeInTheDocument();
|
|
expect(screen.getByText('Berlin')).toBeInTheDocument();
|
|
// Only the assigned label shows — the list's other labels stay hidden.
|
|
expect(screen.queryByText('Nightlife')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-020: the description renders as markdown and links become chips', async () => {
|
|
renderDetail({
|
|
place: {
|
|
...place,
|
|
description: '# Heading\n\nsome **bold** text',
|
|
links: [{ url: 'https://www.example.com/menu', label: 'Menu' }, { url: 'https://tickets.example/x' }],
|
|
},
|
|
});
|
|
expect(await screen.findByRole('heading', { name: 'Heading' })).toBeInTheDocument();
|
|
expect(screen.getByText('bold').tagName).toBe('STRONG');
|
|
expect(screen.getByRole('link', { name: /Menu/ })).toHaveAttribute('href', 'https://www.example.com/menu');
|
|
// Unlabelled link falls back to the bare hostname.
|
|
expect(screen.getByRole('link', { name: /tickets\.example/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-021: the rating control renders only when onRate is supplied', async () => {
|
|
const onRate = vi.fn(async () => {});
|
|
renderDetail({ onRate, place: { ...place, rating_avg: 4 } });
|
|
const stars = await screen.findByRole('radiogroup');
|
|
await userEvent.setup().click(within(stars).getByRole('radio', { name: '5' }));
|
|
expect(onRate).toHaveBeenCalledWith(5);
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-022: edit mode exposes the category picker and switching category sticks', async () => {
|
|
const user = userEvent.setup();
|
|
renderDetail({ canEdit: true, categories: CATEGORIES, place: { ...place, category_id: 1 } });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
|
|
expect(screen.getByText('Category')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /^Food$/ })).toHaveClass('on');
|
|
|
|
await user.click(screen.getByRole('button', { name: /^Museums$/ }));
|
|
expect(screen.getByRole('button', { name: /^Museums$/ })).toHaveClass('on');
|
|
|
|
await user.click(screen.getByRole('button', { name: 'No category' }));
|
|
expect(screen.getByRole('button', { name: 'No category' })).toHaveClass('on');
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-023: saving from edit mode sends the whole patch and leaves edit mode', async () => {
|
|
const user = userEvent.setup();
|
|
const props = renderDetail({
|
|
canEdit: true,
|
|
categories: CATEGORIES,
|
|
labels: LABELS,
|
|
place: { ...place, lat: 52.5, lng: 13.4, label_ids: [1] },
|
|
});
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
|
|
const nameInput = screen.getByLabelText('List name');
|
|
await user.clear(nameInput);
|
|
await user.type(nameInput, ' Renamed Cafe ');
|
|
await user.click(screen.getByRole('button', { name: /^Museums$/ }));
|
|
await user.click(screen.getByRole('button', { name: /Nightlife/ }));
|
|
const desc = screen.getByPlaceholderText('Add a description…');
|
|
await user.clear(desc);
|
|
await user.type(desc, 'Great coffee');
|
|
await user.click(screen.getByRole('button', { name: /Save/ }));
|
|
|
|
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
|
expect(props.onSave).toHaveBeenCalledWith({
|
|
name: 'Renamed Cafe',
|
|
description: 'Great coffee',
|
|
links: [{ label: undefined, url: 'https://x.com' }],
|
|
category_id: 2,
|
|
label_ids: [1, 2],
|
|
// Untouched in this case, but the address rides along on every save (#1870).
|
|
address: 'Somewhere',
|
|
lat: 52.5,
|
|
lng: 13.4,
|
|
});
|
|
// Back in read mode.
|
|
expect(await screen.findByRole('button', { name: 'Edit' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-024: an empty name and description fall back to the stored name and null', async () => {
|
|
const user = userEvent.setup();
|
|
const props = renderDetail({ canEdit: true, place: { ...place, description: 'Nice spot', links: [] } });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
|
|
await user.clear(screen.getByLabelText('List name'));
|
|
await user.clear(screen.getByPlaceholderText('Add a description…'));
|
|
await user.click(screen.getByRole('button', { name: /Save/ }));
|
|
|
|
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
|
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({
|
|
name: 'Test Cafe',
|
|
description: null,
|
|
lat: null,
|
|
lng: null,
|
|
}));
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-025: a failed save toasts the server message and keeps the form open', async () => {
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn(() => Promise.reject({ response: { data: { error: 'Name taken' } } }));
|
|
renderDetail({ canEdit: true, onSave });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
await user.click(screen.getByRole('button', { name: /Save/ }));
|
|
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Name taken', 'error', undefined));
|
|
expect(screen.getByLabelText('List name')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-026: Cancel discards the edits and returns to read mode', async () => {
|
|
const user = userEvent.setup();
|
|
const props = renderDetail({ canEdit: true });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
|
|
await user.type(screen.getByLabelText('List name'), ' Extra');
|
|
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
|
|
expect(props.onSave).not.toHaveBeenCalled();
|
|
expect(screen.getByRole('heading', { name: 'Test Cafe' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-027: links can be added, relabelled and removed in edit mode', async () => {
|
|
const user = userEvent.setup();
|
|
const props = renderDetail({ canEdit: true, place: { ...place, links: [] } });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
|
|
await user.click(screen.getByRole('button', { name: /Add link/ }));
|
|
await user.type(screen.getByPlaceholderText('Label'), 'Site');
|
|
await user.type(screen.getByPlaceholderText('https://…'), 'example.com/menu');
|
|
|
|
await user.click(screen.getByRole('button', { name: /Save/ }));
|
|
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
|
// A bare host is normalized to an absolute https url before it is saved.
|
|
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({
|
|
links: [{ label: 'Site', url: 'https://example.com/menu' }],
|
|
}));
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-028: an emptied link row is dropped from the saved patch', async () => {
|
|
const user = userEvent.setup();
|
|
const props = renderDetail({ canEdit: true, place: { ...place, links: [{ url: 'https://x.com' }] } });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
|
|
// Two rows: the existing link plus a blank one that never gets a url.
|
|
await user.click(screen.getByRole('button', { name: /Add link/ }));
|
|
expect(screen.getAllByPlaceholderText('https://…')).toHaveLength(2);
|
|
|
|
await user.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
|
|
await user.click(screen.getByRole('button', { name: /Save/ }));
|
|
|
|
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
|
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({ links: [] }));
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-029: pasting a "lat, lng" pair into the latitude field fills both inputs', async () => {
|
|
const user = userEvent.setup();
|
|
const props = renderDetail({ canEdit: true });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
|
|
const latInput = screen.getByPlaceholderText('Latitude (e.g. 48.8566)');
|
|
fireEvent.paste(latInput, { clipboardData: { getData: () => ' 48.8566, 2.3522 ' } });
|
|
|
|
expect(latInput).toHaveValue('48.8566');
|
|
expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('2.3522');
|
|
|
|
await user.click(screen.getByRole('button', { name: /Save/ }));
|
|
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
|
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({ lat: 48.8566, lng: 2.3522 }));
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-030: a paste that is not a coordinate pair is left to the input', async () => {
|
|
const user = userEvent.setup();
|
|
renderDetail({ canEdit: true });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
|
|
const latInput = screen.getByPlaceholderText('Latitude (e.g. 48.8566)');
|
|
fireEvent.paste(latInput, { clipboardData: { getData: () => 'somewhere nice' } });
|
|
|
|
expect(latInput).toHaveValue('');
|
|
expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('');
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-031: opening a different place resets the form back to read mode', async () => {
|
|
const user = userEvent.setup();
|
|
const props = renderDetail({ canEdit: true });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
expect(screen.getByLabelText('List name')).toBeInTheDocument();
|
|
|
|
const other: CollectionPlace = { ...place, id: 2, name: 'Second Bar', description: null, links: [] };
|
|
render(<TranslatedDetail {...{ ...props, place: other }} />);
|
|
|
|
// The freshly mounted sheet shows the new place in read mode.
|
|
expect(await screen.findByRole('heading', { name: 'Second Bar' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-032: the labels field is omitted when the list defines none', async () => {
|
|
const user = userEvent.setup();
|
|
renderDetail({ canEdit: true, labels: [] });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
|
|
expect(screen.getByText('Coordinates')).toBeInTheDocument();
|
|
expect(screen.queryByText('Labels')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-033: the anchor rect docks the sheet over the given column', async () => {
|
|
renderDetail({ anchorRect: { left: 320, width: 420 } });
|
|
await screen.findByRole('heading', { name: 'Test Cafe' });
|
|
|
|
const sheet = document.querySelector('.col-detail') as HTMLElement;
|
|
expect(sheet).toHaveClass('docked');
|
|
expect(sheet.style.left).toBe('320px');
|
|
expect(sheet.style.width).toBe('420px');
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-034: the header close button hands back to the caller', async () => {
|
|
const user = userEvent.setup();
|
|
const props = renderDetail();
|
|
await user.click(await screen.findByRole('button', { name: 'Close' }));
|
|
expect(props.onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-035: the remove action fires onRemove for an admin', async () => {
|
|
const user = userEvent.setup();
|
|
const props = renderDetail({ canDelete: true });
|
|
await user.click(await screen.findByRole('button', { name: 'Remove from list' }));
|
|
expect(props.onRemove).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// ── Directions (#1455) ───────────────────────────────────────────────────────
|
|
|
|
describe('CollectionPlaceDetail — open in maps', () => {
|
|
const located: CollectionPlace = { ...place, lat: 48.8584, lng: 2.2945, name: 'Eiffel Tower' };
|
|
|
|
it('FE-COMP-COLDETAIL-036: a located place offers every map app', async () => {
|
|
const user = userEvent.setup();
|
|
renderDetail({ place: located });
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Navigation' }));
|
|
|
|
const menu = await screen.findByRole('menu');
|
|
expect(within(menu).getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument();
|
|
expect(within(menu).getByRole('menuitem', { name: 'Waze' })).toBeInTheDocument();
|
|
expect(within(menu).getByRole('menuitem', { name: 'OpenStreetMap' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-037: opening a target hands the coordinates and the name over', async () => {
|
|
const user = userEvent.setup();
|
|
const open = vi.spyOn(window, 'open').mockReturnValue(null);
|
|
renderDetail({ place: located });
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Navigation' }));
|
|
await user.click(await screen.findByRole('menuitem', { name: 'Waze' }));
|
|
|
|
// Both, so it lands on the place rather than on a bare pin.
|
|
const url = String(open.mock.calls[0][0]);
|
|
expect(url).toContain('48.8584,2.2945');
|
|
expect(url).toContain(encodeURIComponent('Eiffel Tower'));
|
|
open.mockRestore();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-038: a place with only a name and an address still opens a search', async () => {
|
|
const user = userEvent.setup();
|
|
const open = vi.spyOn(window, 'open').mockReturnValue(null);
|
|
// No coordinates and no provider id — the state a place typed in by hand is
|
|
// in. The apps that need a pin drop out; the two that can take a search
|
|
// string are still offered.
|
|
renderDetail();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Navigation' }));
|
|
const menu = await screen.findByRole('menu');
|
|
expect(within(menu).queryByRole('menuitem', { name: 'Waze' })).toBeNull();
|
|
|
|
await user.click(within(menu).getByRole('menuitem', { name: 'Google Maps' }));
|
|
|
|
expect(String(open.mock.calls[0][0])).toContain(encodeURIComponent('Test Cafe, Somewhere'));
|
|
open.mockRestore();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-039: a place with nothing to locate it offers no button at all', () => {
|
|
renderDetail({ place: { ...place, name: '', address: null } });
|
|
|
|
expect(screen.queryByRole('button', { name: /navigation|google maps/i })).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── Editable address (#1870) ─────────────────────────────────────────────────
|
|
|
|
describe('CollectionPlaceDetail: editable address', () => {
|
|
it('FE-COMP-COLDETAIL-040: edit mode offers an address field seeded from the place', async () => {
|
|
const user = userEvent.setup();
|
|
renderDetail({ canEdit: true });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
|
|
expect(screen.getByText('Address')).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('Somewhere');
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-041: a corrected address is trimmed into the patch', async () => {
|
|
const user = userEvent.setup();
|
|
const props = renderDetail({ canEdit: true });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
|
|
const addressInput = screen.getByPlaceholderText('Street, City, Country');
|
|
await user.clear(addressInput);
|
|
await user.type(addressInput, ' Via Nuova 1 ');
|
|
await user.click(screen.getByRole('button', { name: /Save/ }));
|
|
|
|
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
|
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({ address: 'Via Nuova 1' }));
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-042: an emptied address is sent as null', async () => {
|
|
const user = userEvent.setup();
|
|
const props = renderDetail({ canEdit: true });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
|
|
await user.clear(screen.getByPlaceholderText('Street, City, Country'));
|
|
await user.click(screen.getByRole('button', { name: /Save/ }));
|
|
|
|
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
|
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({ address: null }));
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-043: Cancel restores the stored address', async () => {
|
|
const user = userEvent.setup();
|
|
const props = renderDetail({ canEdit: true });
|
|
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
|
|
|
await user.type(screen.getByPlaceholderText('Street, City, Country'), ' 2');
|
|
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
expect(props.onSave).not.toHaveBeenCalled();
|
|
expect(screen.getByText('Somewhere')).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Edit' }));
|
|
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('Somewhere');
|
|
});
|
|
});
|
|
|
|
// ── Cover controls sit beside the close button ───────────────────────────────
|
|
|
|
describe('CollectionPlaceDetail: cover controls', () => {
|
|
it('FE-COMP-COLDETAIL-044: the camera sits in the top right row, just left of the close button', async () => {
|
|
renderDetail({ canEdit: true, onUploadImage: vi.fn() });
|
|
const camera = await screen.findByRole('button', { name: 'Upload image' });
|
|
const close = screen.getByRole('button', { name: 'Close' });
|
|
|
|
const row = close.parentElement as HTMLElement;
|
|
expect(camera.parentElement).toBe(row);
|
|
expect(camera.nextElementSibling).toBe(close);
|
|
// Same class as the close button, so the same round look, hover and phone size.
|
|
expect(camera).toHaveClass('col-detail-close');
|
|
// The category chip keeps the top left corner to itself.
|
|
const chip = screen.getByText('Food').closest('.col-detail-cover-cat') as HTMLElement;
|
|
expect(row.contains(chip)).toBe(false);
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-045: with a custom cover the row reads remove, change, close and change still opens the picker', async () => {
|
|
const user = userEvent.setup();
|
|
renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: { ...place, image_url: '/uploads/places/mock.jpg' } });
|
|
const change = await screen.findByRole('button', { name: 'Change image' });
|
|
const remove = screen.getByRole('button', { name: 'Remove image' });
|
|
const close = screen.getByRole('button', { name: 'Close' });
|
|
|
|
expect(Array.from((close.parentElement as HTMLElement).querySelectorAll('button'))).toEqual([remove, change, close]);
|
|
expect(remove).toHaveClass('col-detail-close');
|
|
|
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
const clicked = vi.fn();
|
|
input.addEventListener('click', clicked);
|
|
await user.click(change);
|
|
expect(clicked).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-046: while an upload runs the camera spins, ignores clicks and the remove control steps aside', async () => {
|
|
const user = userEvent.setup();
|
|
const onUploadImage = vi.fn(() => new Promise<void>(() => {}));
|
|
renderDetail({ canEdit: true, onUploadImage, place: { ...place, image_url: '/uploads/places/mock.jpg' } });
|
|
await screen.findByRole('button', { name: 'Change image' });
|
|
|
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { files: [new File(['x'], 'cover.png', { type: 'image/png' })] } });
|
|
|
|
const change = screen.getByRole('button', { name: 'Change image' });
|
|
await waitFor(() => expect(change.querySelector('.animate-spin')).not.toBeNull());
|
|
expect(screen.queryByRole('button', { name: 'Remove image' })).not.toBeInTheDocument();
|
|
expect(change.nextElementSibling).toBe(screen.getByRole('button', { name: 'Close' }));
|
|
|
|
const clicked = vi.fn();
|
|
input.addEventListener('click', clicked);
|
|
await user.click(change);
|
|
expect(clicked).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-047: a long category shares the top bar with the controls and ellipsizes instead of running under them', async () => {
|
|
const long = 'Restaurants, cafes and late night bars';
|
|
renderDetail({
|
|
canEdit: true,
|
|
onUploadImage: vi.fn(),
|
|
place: { ...place, image_url: '/uploads/places/mock.jpg', category: { id: 1, name: long, color: '#f00', icon: null } },
|
|
});
|
|
const controls = (await screen.findByRole('button', { name: 'Close' })).parentElement as HTMLElement;
|
|
const text = screen.getByText(long);
|
|
const chip = text.closest('.col-detail-cover-cat') as HTMLElement;
|
|
const bar = controls.parentElement as HTMLElement;
|
|
|
|
expect(controls.querySelectorAll('button')).toHaveLength(3);
|
|
// Chip first, controls last in one flex bar: the chip only gets what the buttons leave.
|
|
expect(Array.from(bar.children)).toEqual([chip, controls]);
|
|
expect(bar).toHaveClass('absolute', 'flex', 'left-[14px]', 'right-[12px]', 'top-[12px]');
|
|
expect(controls).toHaveClass('ms-auto', 'flex-none');
|
|
expect(chip).toHaveClass('min-w-0');
|
|
expect(text).toHaveClass('truncate');
|
|
});
|
|
|
|
it('FE-COMP-COLDETAIL-048: without a category the controls still hold the end of the bar', async () => {
|
|
renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: { ...place, category: undefined } });
|
|
const controls = (await screen.findByRole('button', { name: 'Close' })).parentElement as HTMLElement;
|
|
|
|
expect(document.querySelector('.col-detail-cover-cat')).toBeNull();
|
|
expect(Array.from((controls.parentElement as HTMLElement).children)).toEqual([controls]);
|
|
expect(controls).toHaveClass('ms-auto');
|
|
});
|
|
});
|