mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
da679e6266
The dialog built for this last round was its own thing and showed it: the suggestion list was barely legible, clipped at the bottom, and never more than two rows tall. It also lacked most of what the ordinary add-place form has had for years. So it is gone, and "Add manually" opens that form instead, in a service-stop mode. The category control becomes the kind of stop and the time spent there, the costs section is dropped, and an "Add between" row says where on the drive it goes. Everything else comes along rather than being rebuilt: the typed-ahead search with its source marks, the coordinate fields for a charger no index knows, address, website, phone, notes, times, files and the duplicate check. With the mode absent the form is the same component it was, which its own suites still pin. Where the stop lands is worked out when it is saved rather than when the form opens, because the coordinate only exists once a place has been chosen. One function answers both what the select shows and where the stop goes, so the two cannot disagree, and the write goes through the same assignment path as every other insert, via re-anchoring included: a stop pushed into a day shifts every via anchored at or after it, and without re-anchoring first the drawn road runs forward, doubles back and runs out again. The select recommends rather than merely offering. Every leg of every routed day is measured against the place separately and they are listed nearest first, each with its own distance, so the preselected one visibly is the closest and overruling it is an informed choice. The word they all shared moved into the caption. A stop with no position at all is not filed on a drive by guesswork. It keeps its kind and goes to the trip's places, and the form says so and says how to change it.
1946 lines
87 KiB
TypeScript
1946 lines
87 KiB
TypeScript
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -080
|
|
import { render, screen, waitFor, fireEvent, 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 { useTripStore } from '../../store/tripStore';
|
|
import { useAddonStore } from '../../store/addonStore';
|
|
import { usePermissionsStore } from '../../store/permissionsStore';
|
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
|
import { buildUser, buildTrip, buildPlace, buildCategory, buildAssignment } from '../../../tests/helpers/factories';
|
|
import PlaceFormModal from './PlaceFormModal';
|
|
|
|
// Mock CustomTimePicker so we get a simple text input instead of the portal-heavy UI
|
|
vi.mock('../shared/CustomTimePicker', () => ({
|
|
default: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) => (
|
|
<input
|
|
data-testid="time-picker"
|
|
type="text"
|
|
value={value}
|
|
onChange={e => onChange(e.target.value)}
|
|
placeholder={placeholder ?? '00:00'}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
const defaultProps = {
|
|
isOpen: true,
|
|
onClose: vi.fn(),
|
|
onSave: vi.fn(),
|
|
place: null,
|
|
prefillCoords: null,
|
|
tripId: 1,
|
|
categories: [],
|
|
onCategoryCreated: vi.fn(),
|
|
assignmentId: null,
|
|
dayAssignments: [],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false });
|
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
|
});
|
|
|
|
describe('PlaceFormModal', () => {
|
|
it('FE-COMP-PLACEFORM-001: renders modal when isOpen is true', () => {
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
expect(document.body).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-002: shows Add Place title for new place', () => {
|
|
render(<PlaceFormModal {...defaultProps} place={null} />);
|
|
// places.addPlace = "Add Place/Activity"
|
|
expect(screen.getAllByText(/Add Place\/Activity/i).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-003: shows Edit Place title when editing', () => {
|
|
const place = buildPlace({ name: 'Eiffel Tower' });
|
|
render(<PlaceFormModal {...defaultProps} place={place} />);
|
|
expect(screen.getByText('Edit Place')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-004: shows Name field with placeholder', () => {
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-005: shows Description field', () => {
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
expect(screen.getByPlaceholderText(/Short description/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-006: shows Address field', () => {
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
expect(screen.getByPlaceholderText(/Street, City, Country/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-007: shows Add button for new place', () => {
|
|
render(<PlaceFormModal {...defaultProps} place={null} />);
|
|
expect(screen.getByRole('button', { name: /^Add$/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-008: shows Update button when editing', () => {
|
|
const place = buildPlace({ name: 'Test Place' });
|
|
render(<PlaceFormModal {...defaultProps} place={place} />);
|
|
expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-009: shows Cancel button', () => {
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-010: clicking Cancel calls onClose', async () => {
|
|
const user = userEvent.setup();
|
|
const onClose = vi.fn();
|
|
render(<PlaceFormModal {...defaultProps} onClose={onClose} />);
|
|
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-011: pre-fills name field when editing existing place', () => {
|
|
const place = buildPlace({ name: 'Notre Dame' });
|
|
render(<PlaceFormModal {...defaultProps} place={place} />);
|
|
const nameInput = screen.getByDisplayValue('Notre Dame');
|
|
expect(nameInput).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-012: pre-fills address when editing existing place', () => {
|
|
const place = buildPlace({ name: 'Test', address: '123 Main St' });
|
|
render(<PlaceFormModal {...defaultProps} place={place} />);
|
|
expect(screen.getByDisplayValue('123 Main St')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-013: submitting empty form does not call onSave (name required)', async () => {
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn();
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
// Form validation prevents calling onSave without a name
|
|
expect(onSave).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-014: typing in name field and submitting calls onSave', async () => {
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Sacre Coeur');
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Sacre Coeur' }));
|
|
});
|
|
|
|
it('FE-COMP-PLACEFORM-015: categories appear in category selector', () => {
|
|
const cats = [buildCategory({ name: 'Museum' }), buildCategory({ name: 'Park' })];
|
|
render(<PlaceFormModal {...defaultProps} categories={cats} />);
|
|
// Category label is present
|
|
expect(screen.getByText('Category')).toBeInTheDocument();
|
|
});
|
|
|
|
// ── Form initialization ──────────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-PLACEFORM-016: prefillCoords populates lat/lng/name', () => {
|
|
render(
|
|
<PlaceFormModal
|
|
{...defaultProps}
|
|
place={null}
|
|
prefillCoords={{ lat: 48.8566, lng: 2.3522, name: 'Paris', address: 'Paris, France' }}
|
|
/>,
|
|
);
|
|
expect(screen.getByDisplayValue('48.8566')).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue('Paris')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-017: form resets when isOpen changes from place to null', () => {
|
|
const place = buildPlace({ name: 'Old Place' });
|
|
const { rerender } = render(<PlaceFormModal {...defaultProps} place={place} isOpen={true} />);
|
|
expect(screen.getByDisplayValue('Old Place')).toBeInTheDocument();
|
|
|
|
rerender(<PlaceFormModal {...defaultProps} place={null} isOpen={false} />);
|
|
expect(screen.queryByDisplayValue('Old Place')).not.toBeInTheDocument();
|
|
});
|
|
|
|
// ── Maps search ──────────────────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-PLACEFORM-018: maps search populates results via button click', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/maps/search', () =>
|
|
HttpResponse.json({
|
|
places: [{ name: 'Eiffel Tower', address: 'Paris', lat: '48.8584', lng: '2.2945' }],
|
|
}),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const searchInput = screen.getByPlaceholderText('Search places...');
|
|
await user.type(searchInput, 'Eiffel Tower');
|
|
|
|
// The search button is the sibling button of the search input
|
|
const searchRow = searchInput.closest('.flex') as HTMLElement;
|
|
const searchBtn = within(searchRow).getByRole('button');
|
|
await user.click(searchBtn);
|
|
|
|
expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-019: pressing Enter in search input triggers search', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/maps/search', () =>
|
|
HttpResponse.json({
|
|
places: [{ name: 'Eiffel Tower', address: 'Paris', lat: '48.8584', lng: '2.2945' }],
|
|
}),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const searchInput = screen.getByPlaceholderText('Search places...');
|
|
await user.type(searchInput, 'Eiffel Tower');
|
|
await user.keyboard('{Enter}');
|
|
|
|
expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-020: clicking a maps result fills the form', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/maps/search', () =>
|
|
HttpResponse.json({
|
|
places: [{ name: 'Eiffel Tower', address: 'Paris', lat: '48.8584', lng: '2.2945' }],
|
|
}),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const searchInput = screen.getByPlaceholderText('Search places...');
|
|
await user.type(searchInput, 'Eiffel Tower');
|
|
await user.keyboard('{Enter}');
|
|
|
|
const resultBtn = await screen.findByText('Eiffel Tower');
|
|
await user.click(resultBtn);
|
|
|
|
expect(screen.getByDisplayValue('Eiffel Tower')).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue('48.8584')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-021: maps search error surfaces the server-provided reason', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
|
|
const user = userEvent.setup();
|
|
// The backend forwards the real upstream error (e.g. a Google Places API message);
|
|
// the modal must show it instead of a generic "search failed" so the cause is visible.
|
|
server.use(
|
|
http.post('/api/maps/search', () =>
|
|
HttpResponse.json({ error: 'Places API (New) has not been used in project 123 or it is disabled' }, { status: 403 })),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const searchInput = screen.getByPlaceholderText('Search places...');
|
|
await user.type(searchInput, 'someplace');
|
|
await user.keyboard('{Enter}');
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith(
|
|
expect.stringMatching(/Places API \(New\) has not been used/i),
|
|
'error',
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
delete window.__addToast;
|
|
});
|
|
|
|
// ── Autocomplete suggestion click (#1192) ─────────────────────────────────────
|
|
// Selecting a dropdown suggestion does a second `details` lookup which is fragile
|
|
// (details kill-switch, an overloaded OSM Overpass mirror, upstream errors). When
|
|
// it yields no usable place the modal must fall back to the reliable text search
|
|
// instead of dead-ending on "Place search failed".
|
|
|
|
async function openSuggestion(user: ReturnType<typeof userEvent.setup>) {
|
|
const searchInput = screen.getByPlaceholderText('Search places...');
|
|
await user.type(searchInput, 'Eiffel');
|
|
// Debounced autocomplete (300ms) then the dropdown renders the suggestion.
|
|
return screen.findByText('Paris, France');
|
|
}
|
|
|
|
it('FE-PLANNER-PLACEFORM-021b: suggestion click falls back to search when details fails', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/maps/autocomplete', () =>
|
|
HttpResponse.json({
|
|
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
|
source: 'nominatim',
|
|
}),
|
|
),
|
|
// details rejects (e.g. proxy 504 from a hung Overpass mirror)
|
|
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
|
http.post('/api/maps/search', () =>
|
|
HttpResponse.json({
|
|
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
|
source: 'openstreetmap',
|
|
}),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const suggestion = await openSuggestion(user);
|
|
await user.click(suggestion);
|
|
|
|
// Form is populated from the search fallback, and no error toast is shown.
|
|
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue('2.2945')).toBeInTheDocument();
|
|
expect(addToast).not.toHaveBeenCalledWith(expect.anything(), 'error', expect.anything());
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-021e: an OpenStreetMap suggestion keeps its own coordinates instead of searching for its label', async () => {
|
|
// The layer's second line is the name written on the building, not an
|
|
// address. Joining the two and searching for it asks a question nobody
|
|
// typed, and whatever came back first was taken as the place the user had
|
|
// already picked. The row carries coordinates, so the fallback uses those.
|
|
const user = userEvent.setup();
|
|
let searched = 0;
|
|
server.use(
|
|
http.post('/api/maps/autocomplete', () =>
|
|
HttpResponse.json({
|
|
suggestions: [{
|
|
placeId: 'node:9712313',
|
|
mainText: 'Tokio Hauptbahnhof',
|
|
secondaryText: '東京駅丸の内駅舎',
|
|
source: 'openstreetmap',
|
|
lat: 35.6811816,
|
|
lng: 139.76598265,
|
|
}],
|
|
source: 'trek-places',
|
|
}),
|
|
),
|
|
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
|
http.post('/api/maps/search', () => {
|
|
searched += 1;
|
|
return HttpResponse.json({ places: [{ name: 'Etwas ganz anderes', lat: '1', lng: '1' }], source: 'trek-places' });
|
|
}),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'Tokio');
|
|
await user.click(await screen.findByText('Tokio Hauptbahnhof'));
|
|
|
|
expect(await screen.findByDisplayValue('35.6811816')).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue('139.76598265')).toBeInTheDocument();
|
|
expect(searched).toBe(0);
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/maps/autocomplete', () =>
|
|
HttpResponse.json({
|
|
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
|
source: 'nominatim',
|
|
}),
|
|
),
|
|
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
|
http.post('/api/maps/search', () =>
|
|
HttpResponse.json({
|
|
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
|
source: 'openstreetmap',
|
|
}),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const suggestion = await openSuggestion(user);
|
|
await user.click(suggestion);
|
|
|
|
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-021d: suggestion click shows error only when the fallback also finds nothing', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/maps/autocomplete', () =>
|
|
HttpResponse.json({
|
|
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
|
source: 'nominatim',
|
|
}),
|
|
),
|
|
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
|
http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'openstreetmap' })),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const suggestion = await openSuggestion(user);
|
|
await user.click(suggestion);
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith('Place search failed.', 'error', undefined);
|
|
});
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-022: no standing notice about which index is in use', () => {
|
|
// It used to say "Using OpenStreetMap" above the box whenever no Google key
|
|
// was set, which was a sentence about configuration in the middle of a form
|
|
// about a place. Each result names its own source now, which is the question
|
|
// people actually had.
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
expect(screen.queryByText(/Using OpenStreetMap/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-022b: a suggestion says which index answered', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/maps/autocomplete', () =>
|
|
HttpResponse.json({
|
|
suggestions: [{ placeId: 'gers:abc', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
|
source: 'trek-places',
|
|
}),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
|
|
|
expect(await screen.findByText('TREK')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-022d: the typed-ahead list marks the OpenStreetMap rows as such', async () => {
|
|
// The keystroke path asks both indexes at once, so the name the response
|
|
// carries for the whole list ('trek-places') is true of the call and wrong
|
|
// for half the rows. Marking every suggestion TREK is how a list that did
|
|
// contain OpenStreetMap places read as if it never had.
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/maps/autocomplete', () =>
|
|
HttpResponse.json({
|
|
suggestions: [
|
|
{ placeId: 'gers:abc', mainText: 'Tokyo Station Beer Stand', secondaryText: 'Chiyoda', source: 'trek-places' },
|
|
{ placeId: 'node:9712313', mainText: 'Tokio Hauptbahnhof', secondaryText: '東京駅', source: 'openstreetmap' },
|
|
],
|
|
source: 'trek-places',
|
|
}),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'Tokyo Station');
|
|
|
|
expect(await screen.findByText('TREK')).toBeInTheDocument();
|
|
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-022c: an interleaved result list marks each row with its own index', async () => {
|
|
// The whole point of putting it on the row: with both indexes answering,
|
|
// a label above the list could only name one of them. The index marks its
|
|
// own rows, so the unmarked one is OpenStreetMap by elimination.
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/maps/search', () =>
|
|
HttpResponse.json({
|
|
places: [
|
|
{ name: 'Eiffel Tower', address: 'Paris', lat: '48.85', lng: '2.29', source: 'trek-places' },
|
|
{ name: 'Champ de Mars', address: 'Paris', lat: '48.85', lng: '2.29' },
|
|
],
|
|
source: 'trek-places+openstreetmap',
|
|
}),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
|
await user.keyboard('{Enter}');
|
|
|
|
expect(await screen.findByText('TREK')).toBeInTheDocument();
|
|
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-022d: an Amap list marks its rows, and a pick keeps the Amap id', async () => {
|
|
// Amap answers from the slot Google otherwise holds, so its rows get the
|
|
// same mark the other sources get. The id travels with the place: it is
|
|
// what lets the saved place keep opening against Amap after the admin
|
|
// switches providers.
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn();
|
|
server.use(
|
|
http.post('/api/maps/search', () =>
|
|
HttpResponse.json({
|
|
places: [
|
|
{ name: '天安门', address: '北京市东城区', lat: '39.9087', lng: '116.3975', amap_poi_id: 'amap:B000A7BD6C', source: 'amap' },
|
|
],
|
|
source: 'amap',
|
|
}),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'Tiananmen');
|
|
await user.keyboard('{Enter}');
|
|
|
|
expect(await screen.findByText('Amap')).toBeInTheDocument();
|
|
await user.click(screen.getByText('天安门'));
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave.mock.calls[0][0].amap_poi_id).toBe('amap:B000A7BD6C');
|
|
});
|
|
|
|
// ── Category ─────────────────────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-PLACEFORM-023: category selector renders options', () => {
|
|
// The component conditionally shows CustomSelect (showNewCategory=false) or text input
|
|
// Default state shows CustomSelect; no visible "+" trigger exists in current code
|
|
const cats = [buildCategory({ name: 'Beaches' }), buildCategory({ name: 'Museums' })];
|
|
render(<PlaceFormModal {...defaultProps} categories={cats} />);
|
|
// The "No category" placeholder text from CustomSelect should be visible
|
|
expect(screen.getByText(/No category/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-023b: editing a place shows its assigned category, not the placeholder (#1134)', () => {
|
|
// Regression: form.category_id is a string but the option values were numbers,
|
|
// so CustomSelect's strict-equality match failed and the trigger fell back to
|
|
// "No category". With string option values the chosen category renders.
|
|
const cat = buildCategory({ name: 'Museums' });
|
|
const place = buildPlace({ name: 'Louvre', category_id: cat.id });
|
|
render(<PlaceFormModal {...defaultProps} place={place} categories={[cat]} />);
|
|
// Dropdown is closed, so the only place the category name can appear is the trigger.
|
|
expect(screen.getByText('Museums')).toBeInTheDocument();
|
|
expect(screen.queryByText(/No category/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => {
|
|
const user = userEvent.setup();
|
|
const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
|
|
|
|
render(<PlaceFormModal {...defaultProps} onCategoryCreated={onCategoryCreated} />);
|
|
expect(onCategoryCreated).not.toHaveBeenCalled();
|
|
|
|
// The plus next to the category select swaps in the inline name field.
|
|
await user.click(screen.getByRole('button', { name: 'New category' }));
|
|
await user.type(screen.getByPlaceholderText('Category name'), 'Beaches');
|
|
await user.click(screen.getByRole('button', { name: 'OK' }));
|
|
|
|
expect(onCategoryCreated).toHaveBeenCalledWith({ name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
|
|
// The new category is selected and the inline field closes again.
|
|
await waitFor(() => expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-024b: an empty category name does not call onCategoryCreated', async () => {
|
|
const user = userEvent.setup();
|
|
const onCategoryCreated = vi.fn();
|
|
|
|
render(<PlaceFormModal {...defaultProps} onCategoryCreated={onCategoryCreated} />);
|
|
await user.click(screen.getByRole('button', { name: 'New category' }));
|
|
await user.click(screen.getByRole('button', { name: 'OK' }));
|
|
|
|
expect(onCategoryCreated).not.toHaveBeenCalled();
|
|
|
|
const inlineRow = screen.getByPlaceholderText('Category name').parentElement!;
|
|
await user.click(within(inlineRow).getByRole('button', { name: 'Cancel' }));
|
|
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
|
|
});
|
|
|
|
// ── Time section (edit mode only) ────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-PLACEFORM-025: time section is NOT shown in create mode', () => {
|
|
render(<PlaceFormModal {...defaultProps} place={null} />);
|
|
// English labels are 'Start' and 'End' (places.startTime / places.endTime)
|
|
expect(screen.queryByText(/^Start$/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/^End$/i)).not.toBeInTheDocument();
|
|
// Also verify no time pickers rendered
|
|
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-026: time section is hidden in edit mode when no assignment is in context', () => {
|
|
// Times are per day-assignment; editing a pool place with no day in context
|
|
// (assignmentId null) hides the fields, which otherwise would not persist (#1247)
|
|
const place = buildPlace({ name: 'Test' });
|
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
|
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-026b: time section IS shown when an assignment is in context', () => {
|
|
const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '10:00' });
|
|
const assignment = buildAssignment({ id: 10, day_id: 5, place });
|
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} />);
|
|
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-026c: hydrates Start/End from the assignment when the pool place lacks times (#1247)', () => {
|
|
// The pool Place carries no times — they live on the day-assignment. Opening the
|
|
// editor with an assignmentId must hydrate the fields from assignment.place, not
|
|
// the (timeless) pool place that the Places panel passes in.
|
|
const poolPlace = buildPlace({ id: 7, name: 'Museum' });
|
|
const assignmentPlace = buildPlace({ id: 7, name: 'Museum', place_time: '20:20', end_time: '20:34' });
|
|
const assignment = buildAssignment({ id: 42, day_id: 3, place: assignmentPlace });
|
|
render(<PlaceFormModal {...defaultProps} place={poolPlace} assignmentId={42} dayAssignments={[assignment]} />);
|
|
expect(screen.getByDisplayValue('20:20')).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue('20:34')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
|
|
// Build an assignment whose place has end_time before place_time
|
|
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
|
|
const assignment = buildAssignment({ id: 11, day_id: 5, place });
|
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={11} dayAssignments={[assignment]} />);
|
|
|
|
// hasTimeError = true → submit button disabled
|
|
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
|
|
expect(submitBtn).toBeDisabled();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-028: time collision warning appears when assignments overlap', () => {
|
|
// Create an assignment for the "current" place being edited
|
|
const currentPlace = buildPlace({ name: 'My Event', place_time: '12:30', end_time: '13:30' });
|
|
const conflictingPlace = buildPlace({ name: 'Other Event', place_time: '13:00', end_time: '14:00' });
|
|
|
|
const currentAssignment = buildAssignment({ id: 10, day_id: 5, place: currentPlace });
|
|
const otherAssignment = buildAssignment({ id: 20, day_id: 5, place: conflictingPlace });
|
|
|
|
render(
|
|
<PlaceFormModal
|
|
{...defaultProps}
|
|
place={currentPlace}
|
|
assignmentId={10}
|
|
dayAssignments={[currentAssignment, otherAssignment]}
|
|
/>,
|
|
);
|
|
|
|
// English translation: 'places.timeCollision' = 'Time overlap with:'
|
|
expect(screen.getByText(/Time overlap with:/i)).toBeInTheDocument();
|
|
});
|
|
|
|
// ── File attachments ──────────────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-PLACEFORM-029: file attachment section shown when canUploadFiles=true', () => {
|
|
// Default: permissions={} → not configured → allow → canUploadFiles=true
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
expect(screen.getByText('Attach')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-030: file attachment section hidden when canUploadFiles=false', () => {
|
|
// Set file_upload to 'admin' level; non-admin user cannot upload
|
|
seedStore(usePermissionsStore, { permissions: { file_upload: 'admin' } });
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
expect(screen.queryByText('Attach')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-031: pending files list shows file names after adding', async () => {
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
expect(fileInput).toBeTruthy();
|
|
|
|
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
|
|
fireEvent.change(fileInput, { target: { files: [file] } });
|
|
|
|
await screen.findByText('photo.jpg');
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-032: removing a pending file removes it from the list', async () => {
|
|
const user = userEvent.setup();
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
const file = new File(['x'], 'remove-me.jpg', { type: 'image/jpeg' });
|
|
fireEvent.change(fileInput, { target: { files: [file] } });
|
|
await screen.findByText('remove-me.jpg');
|
|
|
|
// The X button is inside the file item's container div
|
|
const fileItem = screen.getByText('remove-me.jpg').closest('div.flex') as HTMLElement;
|
|
const removeBtn = within(fileItem).getByRole('button');
|
|
await user.click(removeBtn);
|
|
|
|
expect(screen.queryByText('remove-me.jpg')).not.toBeInTheDocument();
|
|
});
|
|
|
|
// ── Submit ────────────────────────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-PLACEFORM-033: onSave receives parsed lat/lng as numbers', async () => {
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Notre Dame');
|
|
|
|
const latInput = screen.getByPlaceholderText(/Latitude/i);
|
|
await user.clear(latInput);
|
|
await user.type(latInput, '48.853');
|
|
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ lat: 48.853 }));
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-034: onSave error shows toast', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockRejectedValue(new Error('Server error'));
|
|
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Notre Dame');
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith('Server error', 'error', undefined);
|
|
});
|
|
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-035: save button shows "Saving..." while saving', async () => {
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockReturnValue(new Promise(() => {})); // never resolves
|
|
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Notre Dame');
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-036: lat/lng paste splits "48.8566, 2.3522" into lat and lng fields', () => {
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const latInput = screen.getByPlaceholderText(/Latitude/i);
|
|
|
|
fireEvent.paste(latInput, {
|
|
clipboardData: {
|
|
getData: () => '48.8566, 2.3522',
|
|
},
|
|
});
|
|
|
|
expect(screen.getByDisplayValue('48.8566')).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue('2.3522')).toBeInTheDocument();
|
|
});
|
|
|
|
// ── Duplicate detection (#1152) ────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-PLACEFORM-037: a same-named place warns first and saves only on the second click', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: 'Louvre' })] });
|
|
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
// Case-insensitive match against the existing trip place.
|
|
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), ' louvre ');
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
expect(onSave).not.toHaveBeenCalled();
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Louvre'), 'warning', undefined);
|
|
// The button now reads as the explicit confirmation.
|
|
const addAnyway = await screen.findByRole('button', { name: /Add anyway/i });
|
|
await user.click(addAnyway);
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-038: near-identical coordinates count as a duplicate even under a new name', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
seedStore(useTripStore, {
|
|
trip: buildTrip({ id: 1 }),
|
|
places: [buildPlace({ name: 'Tour Eiffel', lat: 48.8584, lng: 2.2945 })],
|
|
});
|
|
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Iron Lady');
|
|
fireEvent.paste(screen.getByPlaceholderText(/Latitude/i), {
|
|
clipboardData: { getData: () => '48.85845, 2.29455' },
|
|
});
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
expect(onSave).not.toHaveBeenCalled();
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Tour Eiffel'), 'warning', undefined);
|
|
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-039: a far-away place with a fresh name saves straight away', async () => {
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
seedStore(useTripStore, {
|
|
trip: buildTrip({ id: 1 }),
|
|
places: [buildPlace({ name: 'Tour Eiffel', lat: 48.8584, lng: 2.2945 })],
|
|
});
|
|
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Colosseum');
|
|
fireEvent.paste(screen.getByPlaceholderText(/Latitude/i), {
|
|
clipboardData: { getData: () => '41.8902, 12.4922' },
|
|
});
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Colosseum', lat: 41.8902, lng: 12.4922 }));
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-040: editing an existing place never triggers the duplicate guard', async () => {
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
const place = buildPlace({ name: 'Louvre' });
|
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [place] });
|
|
|
|
render(<PlaceFormModal {...defaultProps} place={place} onSave={onSave} />);
|
|
await user.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-041: an empty name is rejected with the name-required toast', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn();
|
|
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
expect(onSave).not.toHaveBeenCalled();
|
|
expect(addToast).toHaveBeenCalledWith('Please enter a name', 'error', undefined);
|
|
|
|
delete window.__addToast;
|
|
});
|
|
|
|
// ── Location bias from the trip's existing places ───────────────────────────
|
|
|
|
it('FE-PLANNER-PLACEFORM-042: a tight cluster of trip places biases the autocomplete bounding box', async () => {
|
|
const user = userEvent.setup();
|
|
const bodies: Record<string, unknown>[] = [];
|
|
server.use(
|
|
http.post('/api/maps/autocomplete', async ({ request }) => {
|
|
bodies.push((await request.json()) as Record<string, unknown>);
|
|
return HttpResponse.json({ suggestions: [] });
|
|
}),
|
|
);
|
|
seedStore(useTripStore, {
|
|
trip: buildTrip({ id: 1 }),
|
|
places: [
|
|
buildPlace({ lat: 48.85, lng: 2.34 }),
|
|
buildPlace({ lat: 48.87, lng: 2.37 }),
|
|
// Garbage coordinates must be skipped, not poison the box.
|
|
buildPlace({ lat: 'x' as unknown as number, lng: 'y' as unknown as number }),
|
|
],
|
|
});
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
|
|
|
await waitFor(() => expect(bodies).toHaveLength(1));
|
|
expect(bodies[0].locationBias).toEqual({
|
|
low: { lat: 48.85, lng: 2.34 },
|
|
high: { lat: 48.87, lng: 2.37 },
|
|
});
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-043: places spread over more than 500 km send no location bias', async () => {
|
|
const user = userEvent.setup();
|
|
const bodies: Record<string, unknown>[] = [];
|
|
server.use(
|
|
http.post('/api/maps/autocomplete', async ({ request }) => {
|
|
bodies.push((await request.json()) as Record<string, unknown>);
|
|
return HttpResponse.json({ suggestions: [] });
|
|
}),
|
|
);
|
|
seedStore(useTripStore, {
|
|
trip: buildTrip({ id: 1 }),
|
|
places: [buildPlace({ lat: 48.85, lng: 2.34 }), buildPlace({ lat: 41.89, lng: 12.49 })],
|
|
});
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
|
|
|
await waitFor(() => expect(bodies).toHaveLength(1));
|
|
expect(bodies[0].locationBias).toBeUndefined();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-043b: trip places with unusable coordinates yield no bias at all', async () => {
|
|
const user = userEvent.setup();
|
|
const bodies: Record<string, unknown>[] = [];
|
|
server.use(
|
|
http.post('/api/maps/autocomplete', async ({ request }) => {
|
|
bodies.push((await request.json()) as Record<string, unknown>);
|
|
return HttpResponse.json({ suggestions: [] });
|
|
}),
|
|
);
|
|
seedStore(useTripStore, {
|
|
trip: buildTrip({ id: 1 }),
|
|
places: [buildPlace({ lat: 'x' as unknown as number, lng: 'y' as unknown as number })],
|
|
});
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
|
|
|
await waitFor(() => expect(bodies).toHaveLength(1));
|
|
expect(bodies[0].locationBias).toBeUndefined();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-045b: focusing with a Google Maps URL in the box fetches no suggestions', async () => {
|
|
const user = userEvent.setup();
|
|
let calls = 0;
|
|
server.use(
|
|
http.post('/api/maps/autocomplete', () => {
|
|
calls += 1;
|
|
return HttpResponse.json({ suggestions: [] });
|
|
}),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const searchInput = screen.getByPlaceholderText('Search places...');
|
|
await user.type(searchInput, 'https://maps.google.com/maps?q=x');
|
|
fireEvent.focus(searchInput);
|
|
|
|
await waitFor(() => expect(screen.queryByText('Paris, France')).not.toBeInTheDocument());
|
|
expect(calls).toBe(0);
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-044: a failing autocomplete clears the dropdown instead of surfacing an error', async () => {
|
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
server.use(http.post('/api/maps/autocomplete', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
|
|
|
await waitFor(() => expect(consoleError).toHaveBeenCalledWith('Autocomplete failed:', expect.anything()));
|
|
expect(addToast).not.toHaveBeenCalled();
|
|
|
|
consoleError.mockRestore();
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-045: re-focusing the search box refetches when the dropdown is empty', async () => {
|
|
const user = userEvent.setup();
|
|
let calls = 0;
|
|
server.use(
|
|
http.post('/api/maps/autocomplete', () => {
|
|
calls += 1;
|
|
return HttpResponse.json({ suggestions: [] });
|
|
}),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const searchInput = screen.getByPlaceholderText('Search places...');
|
|
await user.type(searchInput, 'Eiffel');
|
|
await waitFor(() => expect(calls).toBe(1));
|
|
|
|
fireEvent.focus(searchInput);
|
|
await waitFor(() => expect(calls).toBe(2));
|
|
});
|
|
|
|
// ── Maps search / Google Maps URL resolve ──────────────────────────────────
|
|
|
|
it('FE-PLANNER-PLACEFORM-046: clicking search with an empty box makes no request', async () => {
|
|
const user = userEvent.setup();
|
|
let calls = 0;
|
|
server.use(
|
|
http.post('/api/maps/search', () => {
|
|
calls += 1;
|
|
return HttpResponse.json({ places: [] });
|
|
}),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const searchInput = screen.getByPlaceholderText('Search places...');
|
|
const searchBtn = within(searchInput.closest('.flex') as HTMLElement).getByRole('button');
|
|
await user.click(searchBtn);
|
|
|
|
expect(calls).toBe(0);
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-047: pasting a Google Maps URL resolves it straight into the form', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/maps/resolve-url', () =>
|
|
HttpResponse.json({ name: 'Notre-Dame', address: 'Parvis Notre-Dame, Paris', lat: 48.8530, lng: 2.3499, google_ftid: 'ftid-1' }),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'https://maps.google.com/maps?q=notredame');
|
|
await user.keyboard('{Enter}');
|
|
|
|
expect(await screen.findByDisplayValue('Notre-Dame')).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue('48.853')).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue('Parvis Notre-Dame, Paris')).toBeInTheDocument();
|
|
// The URL is consumed, so the search box empties out.
|
|
expect(screen.getByPlaceholderText('Search places...')).toHaveValue('');
|
|
expect(addToast).toHaveBeenCalledWith('Place imported from URL', 'success', undefined);
|
|
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-048: an unresolvable Google Maps URL falls through to the text search', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/maps/resolve-url', () => HttpResponse.json({ name: 'Notre-Dame', lat: null, lng: null })),
|
|
http.post('/api/maps/search', () =>
|
|
HttpResponse.json({ places: [{ name: 'Notre-Dame de Paris', address: 'Paris', lat: '48.853', lng: '2.3499' }] }),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'https://maps.google.com/maps?q=notredame');
|
|
await user.keyboard('{Enter}');
|
|
|
|
expect(await screen.findByText('Notre-Dame de Paris')).toBeInTheDocument();
|
|
});
|
|
|
|
// ── Autocomplete keyboard navigation ───────────────────────────────────────
|
|
|
|
const twoSuggestions = () =>
|
|
http.post('/api/maps/autocomplete', () =>
|
|
HttpResponse.json({
|
|
suggestions: [
|
|
{ placeId: 'node:1', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' },
|
|
{ placeId: 'node:2', mainText: 'Eiffel Museum', secondaryText: 'Berlin, Germany' },
|
|
],
|
|
}),
|
|
);
|
|
|
|
it('FE-PLANNER-PLACEFORM-049: ArrowDown/ArrowUp move the highlight and Enter picks the highlighted suggestion', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
twoSuggestions(),
|
|
http.get('/api/maps/details/:placeId', () =>
|
|
HttpResponse.json({ place: { name: 'Eiffel Museum', address: 'Berlin', lat: 52.52, lng: 13.405 } }),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const searchInput = screen.getByPlaceholderText('Search places...');
|
|
await user.type(searchInput, 'Eiffel');
|
|
await screen.findByText('Paris, France');
|
|
|
|
// -1 → wraps to the last entry, then ArrowDown wraps back to the first.
|
|
await user.keyboard('{ArrowUp}');
|
|
expect(screen.getByText('Eiffel Museum').closest('button')).toHaveClass('bg-surface-tertiary');
|
|
await user.keyboard('{ArrowDown}');
|
|
expect(screen.getByText('Eiffel Tower').closest('button')).toHaveClass('bg-surface-tertiary');
|
|
await user.keyboard('{ArrowDown}');
|
|
expect(screen.getByText('Eiffel Museum').closest('button')).toHaveClass('bg-surface-tertiary');
|
|
|
|
await user.keyboard('{Enter}');
|
|
expect(await screen.findByDisplayValue('52.52')).toBeInTheDocument();
|
|
expect(screen.getByDisplayValue('13.405')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-050: Escape closes the dropdown without searching', async () => {
|
|
const user = userEvent.setup();
|
|
let searches = 0;
|
|
server.use(
|
|
twoSuggestions(),
|
|
http.post('/api/maps/search', () => {
|
|
searches += 1;
|
|
return HttpResponse.json({ places: [] });
|
|
}),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
|
await screen.findByText('Paris, France');
|
|
|
|
await user.keyboard('{Escape}');
|
|
expect(screen.queryByText('Paris, France')).not.toBeInTheDocument();
|
|
expect(searches).toBe(0);
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-051: Enter with nothing highlighted closes the dropdown and runs the full search', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
twoSuggestions(),
|
|
http.post('/api/maps/search', () =>
|
|
HttpResponse.json({ places: [{ name: 'Eiffel Tower', address: 'Champ de Mars', lat: '48.8584', lng: '2.2945' }] }),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
|
await screen.findByText('Paris, France');
|
|
|
|
await user.keyboard('{Enter}');
|
|
expect(await screen.findByText('Champ de Mars')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-052: when both details and the search fallback fail the query is restored', async () => {
|
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
twoSuggestions(),
|
|
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
|
http.post('/api/maps/search', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
|
await user.click(await screen.findByText('Paris, France'));
|
|
|
|
await waitFor(() =>
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/Place search failed|nope/i), 'error', undefined),
|
|
);
|
|
// The typed query comes back so the user can retry instead of starting over.
|
|
await waitFor(() => expect(screen.getByPlaceholderText('Search places...')).toHaveValue('Eiffel'));
|
|
|
|
consoleError.mockRestore();
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-053: a Google place id shared with an existing trip place is flagged as duplicate', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
seedStore(useTripStore, {
|
|
trip: buildTrip({ id: 1 }),
|
|
places: [buildPlace({ name: 'Already Here', google_place_id: 'gp-1', lat: null, lng: null })],
|
|
});
|
|
server.use(
|
|
http.post('/api/maps/search', () =>
|
|
HttpResponse.json({ places: [{ name: 'Same Spot', address: 'Somewhere', lat: '10', lng: '10', google_place_id: 'gp-1' }] }),
|
|
),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'same spot');
|
|
await user.keyboard('{Enter}');
|
|
await user.click(await screen.findByText('Same Spot'));
|
|
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
expect(onSave).not.toHaveBeenCalled();
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Already Here'), 'warning', undefined);
|
|
|
|
delete window.__addToast;
|
|
});
|
|
|
|
// ── Paste-to-attach ────────────────────────────────────────────────────────
|
|
|
|
function pasteItems(target: Element, items: { type: string; file: File | null }[]) {
|
|
fireEvent.paste(target, {
|
|
clipboardData: {
|
|
items: items.map(i => ({ type: i.type, getAsFile: () => i.file })),
|
|
},
|
|
});
|
|
}
|
|
|
|
it('FE-PLANNER-PLACEFORM-054: pasting an image attaches it as a pending file', () => {
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const form = document.querySelector('form') as HTMLFormElement;
|
|
pasteItems(form, [
|
|
{ type: 'text/plain', file: null },
|
|
{ type: 'image/png', file: new File(['x'], 'screenshot.png', { type: 'image/png' }) },
|
|
]);
|
|
expect(screen.getByText('screenshot.png')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-057: a prefilled place drives the detail column, not the previous one', () => {
|
|
// A POI tapped on the map opens the dialog through prefillCoords, never
|
|
// through the search handler — the column used to keep the last place.
|
|
const { rerender } = render(
|
|
<PlaceFormModal {...defaultProps} prefillCoords={{ lat: 53.55, lng: 9.99, name: 'Helgas Kitchen', osm_id: 'node:1' }} />,
|
|
);
|
|
expect(screen.getByDisplayValue('Helgas Kitchen')).toBeInTheDocument();
|
|
|
|
rerender(
|
|
<PlaceFormModal {...defaultProps} prefillCoords={{ lat: 50.9, lng: 6.96, name: 'Museum Ludwig', osm_id: 'node:2' }} />,
|
|
);
|
|
expect(screen.getByDisplayValue('Museum Ludwig')).toBeInTheDocument();
|
|
expect(screen.queryByDisplayValue('Helgas Kitchen')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-055: pasting plain text alone attaches nothing', () => {
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const form = document.querySelector('form') as HTMLFormElement;
|
|
pasteItems(form, [{ type: 'text/plain', file: null }]);
|
|
// The list only renders once something is attached, so its absence is the
|
|
// assertion (this used to lean on the clipboard hint, which is gone).
|
|
expect(screen.queryByTestId('pending-files')).not.toBeInTheDocument();
|
|
|
|
// Counter-check, so the assertion above cannot pass on a missing testid:
|
|
// a real file does show up in that list.
|
|
pasteItems(form, [{ type: 'image/png', file: new File(['x'], 'shot.png', { type: 'image/png' }) }]);
|
|
expect(screen.getByTestId('pending-files')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-056: paste is ignored entirely without upload permission', () => {
|
|
seedStore(usePermissionsStore, { permissions: { file_upload: 'admin' } });
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const form = document.querySelector('form') as HTMLFormElement;
|
|
pasteItems(form, [{ type: 'application/pdf', file: new File(['x'], 'ticket.pdf', { type: 'application/pdf' }) }]);
|
|
expect(screen.queryByText('ticket.pdf')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-057: the Attach button opens the hidden file input', async () => {
|
|
const user = userEvent.setup();
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
|
|
await user.click(screen.getByRole('button', { name: /Attach/i }));
|
|
expect(clickSpy).toHaveBeenCalled();
|
|
clickSpy.mockRestore();
|
|
});
|
|
|
|
// ── Remaining form fields ──────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-PLACEFORM-058: description, notes, website and category all reach the save payload', async () => {
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
const cat = buildCategory({ name: 'Museums' });
|
|
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} categories={[cat]} />);
|
|
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre');
|
|
await user.type(screen.getByPlaceholderText(/Short description/i), 'Glass pyramid');
|
|
await user.type(screen.getByPlaceholderText(/Personal notes/i), 'Book ahead');
|
|
await user.type(screen.getByPlaceholderText(/Street, City, Country/i), 'Rue de Rivoli');
|
|
await user.type(screen.getByPlaceholderText(/Longitude/i), '2.3376');
|
|
await user.type(screen.getByPlaceholderText('https://...'), 'https://louvre.fr');
|
|
// Pick the category through the CustomSelect trigger.
|
|
await user.click(screen.getByText(/No category/i));
|
|
await user.click(screen.getByRole('button', { name: 'Museums' }));
|
|
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'Louvre',
|
|
description: 'Glass pyramid',
|
|
notes: 'Book ahead',
|
|
address: 'Rue de Rivoli',
|
|
lng: 2.3376,
|
|
website: 'https://louvre.fr',
|
|
category_id: String(cat.id),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-059: the Collections column only appears on desktop with the addon enabled', () => {
|
|
seedStore(useAddonStore, {
|
|
addons: [{ id: 'collections', name: 'Collections', type: 'collections', icon: '', enabled: true }],
|
|
loaded: true,
|
|
});
|
|
const { unmount } = render(<PlaceFormModal {...defaultProps} isMobile={false} />);
|
|
expect(screen.getByPlaceholderText(/Search your saved places/i)).toBeInTheDocument();
|
|
unmount();
|
|
|
|
render(<PlaceFormModal {...defaultProps} isMobile />);
|
|
expect(screen.queryByPlaceholderText(/Search your saved places/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
// ── Time section edge cases ────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-PLACEFORM-060: editing the times through the pickers updates the save payload', async () => {
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
const place = buildPlace({ name: 'Museum' });
|
|
const assignment = buildAssignment({ id: 12, day_id: 4, place });
|
|
|
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={12} dayAssignments={[assignment]} onSave={onSave} />);
|
|
const [start, end] = screen.getAllByTestId('time-picker');
|
|
fireEvent.change(start, { target: { value: '09:00' } });
|
|
fireEvent.change(end, { target: { value: '10:30' } });
|
|
|
|
await user.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ place_time: '09:00', end_time: '10:30' }));
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-061: an assignment id that is not in the day list reports no collisions', () => {
|
|
const place = buildPlace({ name: 'Ghost', place_time: '12:00', end_time: '13:00' });
|
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={999} dayAssignments={[]} />);
|
|
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
|
|
expect(screen.queryByText(/Time overlap with:/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-062: collisions ignore other days and timeless assignments', () => {
|
|
const current = buildPlace({ name: 'My Event', place_time: '12:30', end_time: '13:30' });
|
|
const otherDay = buildPlace({ name: 'Other Day Event', place_time: '13:00', end_time: '14:00' });
|
|
const timeless = buildPlace({ name: 'Timeless Event' });
|
|
const overlapping = buildPlace({ name: 'Overlapping Event', place_time: '13:00', end_time: '14:00' });
|
|
|
|
render(
|
|
<PlaceFormModal
|
|
{...defaultProps}
|
|
place={current}
|
|
assignmentId={10}
|
|
dayAssignments={[
|
|
buildAssignment({ id: 10, day_id: 5, place: current }),
|
|
buildAssignment({ id: 20, day_id: 6, place: otherDay }),
|
|
buildAssignment({ id: 30, day_id: 5, place: timeless }),
|
|
buildAssignment({ id: 40, day_id: 5, place: overlapping }),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
const warning = screen.getByText(/Time overlap with:/i).closest('div') as HTMLElement;
|
|
expect(warning).toHaveTextContent('Overlapping Event');
|
|
expect(warning).not.toHaveTextContent('Other Day Event');
|
|
expect(warning).not.toHaveTextContent('Timeless Event');
|
|
});
|
|
});
|
|
|
|
// FE-W5PFM-001 to FE-W5PFM-014 — the remaining prefill, response-shape and
|
|
// error branches of the place form.
|
|
describe('PlaceFormModal remaining branches', () => {
|
|
it('FE-W5PFM-001: an existing place with empty fields opens on the defaults', () => {
|
|
const place = buildPlace({
|
|
name: null, description: null, address: null, lat: null, lng: null,
|
|
category_id: null, notes: null, transport_mode: null, website: null,
|
|
});
|
|
render(<PlaceFormModal {...defaultProps} place={place} />);
|
|
|
|
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('');
|
|
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('');
|
|
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('');
|
|
expect(screen.getByPlaceholderText('https://...')).toHaveValue('');
|
|
});
|
|
|
|
it('FE-W5PFM-002: prefilled coordinates without a name only fill the coordinates', () => {
|
|
render(<PlaceFormModal {...defaultProps} prefillCoords={{ lat: 48.8584, lng: 2.2945 }} />);
|
|
|
|
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('48.8584');
|
|
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('2.2945');
|
|
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('');
|
|
expect(screen.getByPlaceholderText(/Street, City, Country/i)).toHaveValue('');
|
|
});
|
|
|
|
it('FE-W5PFM-003: an autocomplete response without a suggestions array shows no dropdown', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(http.post('/api/maps/autocomplete', () => HttpResponse.json({})));
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
|
|
|
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
|
|
expect(screen.queryByText('Eiffel Tower, Paris')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5PFM-004: an aborted autocomplete is swallowed without logging', async () => {
|
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const user = userEvent.setup();
|
|
const gate: { release?: () => void } = {};
|
|
let seen = 0;
|
|
server.use(
|
|
http.post('/api/maps/autocomplete', async () => {
|
|
seen += 1;
|
|
if (seen === 1) await new Promise<void>(res => { gate.release = res; });
|
|
return HttpResponse.json({
|
|
suggestions: [{ placeId: 'node:1', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
|
});
|
|
}),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const searchInput = screen.getByPlaceholderText('Search places...');
|
|
await user.type(searchInput, 'Eiff');
|
|
await waitFor(() => expect(seen).toBe(1));
|
|
// The second query aborts the still-open first one
|
|
await user.type(searchInput, 'el tower');
|
|
await waitFor(() => expect(seen).toBe(2));
|
|
gate.release?.();
|
|
|
|
expect(await screen.findByText('Paris, France')).toBeInTheDocument();
|
|
expect(screen.getAllByText('Eiffel Tower')).toHaveLength(1);
|
|
expect(consoleError).not.toHaveBeenCalledWith('Autocomplete failed:', expect.anything());
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
it('FE-W5PFM-005: a resolved URL without a name or address keeps the form values', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/maps/resolve-url', () => HttpResponse.json({ lat: 48.853, lng: 2.3499 })),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Kept Name');
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'https://maps.google.com/maps?q=x');
|
|
await user.keyboard('{Enter}');
|
|
|
|
expect(await screen.findByDisplayValue('48.853')).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('Kept Name');
|
|
});
|
|
|
|
it('FE-W5PFM-006: a search response without a places array renders no results', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(http.post('/api/maps/search', () => HttpResponse.json({})));
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'nowhere');
|
|
await user.keyboard('{Enter}');
|
|
|
|
await waitFor(() => expect(screen.getByPlaceholderText('Search places...')).toHaveValue('nowhere'));
|
|
expect(screen.queryByRole('button', { name: /nowhere/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5PFM-007: picking a bare search result leaves the typed fields alone', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Bare Result' }] })),
|
|
);
|
|
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
await user.type(screen.getByPlaceholderText(/Street, City, Country/i), 'Rue de Rivoli');
|
|
await user.type(screen.getByPlaceholderText('Search places...'), 'bare');
|
|
await user.keyboard('{Enter}');
|
|
await user.click(await screen.findByText('Bare Result'));
|
|
|
|
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('Bare Result');
|
|
expect(screen.getByPlaceholderText(/Street, City, Country/i)).toHaveValue('Rue de Rivoli');
|
|
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('');
|
|
});
|
|
|
|
it('FE-W5PFM-008: a place sharing only the name is flagged as a duplicate', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: 'Louvre', lat: null, lng: null })] });
|
|
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'louvre');
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
expect(onSave).not.toHaveBeenCalled();
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Louvre'), 'warning', undefined);
|
|
|
|
// The second click is the explicit confirmation
|
|
await user.click(screen.getByRole('button', { name: /Add anyway/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-W5PFM-009: a place sharing only the coordinates is flagged as a duplicate', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: null, lat: 48.8584, lng: 2.2945 })] });
|
|
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Tour Eiffel');
|
|
await user.type(screen.getByPlaceholderText(/Latitude/i), '48.85841');
|
|
await user.type(screen.getByPlaceholderText(/Longitude/i), '2.29451');
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
expect(onSave).not.toHaveBeenCalled();
|
|
// the existing place has no name, so the warning falls back to the typed one
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Tour Eiffel'), 'warning', undefined);
|
|
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-W5PFM-010: a rejected save without an Error surfaces the generic message', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn().mockRejectedValue('nope');
|
|
const onClose = vi.fn();
|
|
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} onClose={onClose} />);
|
|
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre');
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to save', 'error', undefined));
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-W5PFM-011: pasting a coordinate pair into latitude fills both fields', () => {
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const lat = screen.getByPlaceholderText(/Latitude/i);
|
|
|
|
fireEvent.paste(lat, { clipboardData: { getData: () => ' 48.8584, 2.2945 ' } });
|
|
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('48.8584');
|
|
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('2.2945');
|
|
});
|
|
|
|
it('FE-W5PFM-012: pasting anything else into latitude is left to the input', () => {
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
const lat = screen.getByPlaceholderText(/Latitude/i);
|
|
|
|
fireEvent.paste(lat, { clipboardData: { getData: () => 'not a coordinate' } });
|
|
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('');
|
|
});
|
|
|
|
it('FE-W5PFM-013: a missing category list still renders the no-category option', () => {
|
|
render(<PlaceFormModal {...defaultProps} categories={null} />);
|
|
|
|
expect(screen.getByText(/No category/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-W5PFM-014: an open-ended assignment collapses to a point in time', () => {
|
|
const current = buildPlace({ name: 'My Event', place_time: '11:30', end_time: '' });
|
|
const spanning = buildPlace({ name: 'Spanning Event', place_time: '11:00', end_time: '12:00' });
|
|
const earlier = buildPlace({ name: 'Earlier Event', place_time: '11:00', end_time: '' });
|
|
|
|
render(
|
|
<PlaceFormModal
|
|
{...defaultProps}
|
|
place={current}
|
|
assignmentId={10}
|
|
dayAssignments={[
|
|
buildAssignment({ id: 10, day_id: 5, place: current }),
|
|
buildAssignment({ id: 20, day_id: 5, place: spanning }),
|
|
buildAssignment({ id: 30, day_id: 5, place: earlier }),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
const warning = screen.getByText(/Time overlap with:/i).closest('div') as HTMLElement;
|
|
expect(warning).toHaveTextContent('Spanning Event');
|
|
expect(warning).not.toHaveTextContent('Earlier Event');
|
|
});
|
|
|
|
/**
|
|
* FE-PLANNER-PLACEFORM-063..068 — what a second pick may overwrite.
|
|
*
|
|
* Search Hamburg Airport, then Berlin Hauptbahnhof, and the airport's website
|
|
* was still in the field: `result.website || prev.website` cannot tell "the
|
|
* user typed this" from "the last result wrote this". Saving then gave the
|
|
* station the airport's website.
|
|
*/
|
|
describe('picking a second place', () => {
|
|
const searchFor = async (user: ReturnType<typeof userEvent.setup>, place: Record<string, unknown>) => {
|
|
server.use(http.post('/api/maps/search', () => HttpResponse.json({ places: [place] })));
|
|
const searchInput = screen.getByPlaceholderText('Search places...');
|
|
await user.clear(searchInput);
|
|
await user.type(searchInput, String(place.name));
|
|
const searchRow = searchInput.closest('.flex') as HTMLElement;
|
|
await user.click(within(searchRow).getByRole('button'));
|
|
await user.click(await screen.findByText(String(place.name)));
|
|
};
|
|
|
|
const AIRPORT = {
|
|
name: 'Hamburg Airport',
|
|
address: 'Hamburg',
|
|
lat: '53.6323',
|
|
lng: '10.0067',
|
|
website: 'https://www.hamburg-airport.de/',
|
|
};
|
|
const STATION = { name: 'Berlin Hauptbahnhof', address: 'Berlin', lat: '52.525', lng: '13.3694' };
|
|
|
|
const websiteInput = () => screen.getByPlaceholderText('https://...') as HTMLInputElement;
|
|
|
|
it('FE-PLANNER-PLACEFORM-063: a place with no website clears the previous one', async () => {
|
|
const user = userEvent.setup();
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
|
|
await searchFor(user, AIRPORT);
|
|
expect(websiteInput().value).toBe('https://www.hamburg-airport.de/');
|
|
|
|
await searchFor(user, STATION);
|
|
expect(websiteInput().value).toBe('');
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-064: a website the user typed survives the next pick', async () => {
|
|
const user = userEvent.setup();
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
|
|
await user.type(websiteInput(), 'https://mine.example');
|
|
await searchFor(user, STATION);
|
|
|
|
expect(websiteInput().value).toBe('https://mine.example');
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-065: a picked value the user then edited also survives', async () => {
|
|
const user = userEvent.setup();
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
|
|
await searchFor(user, AIRPORT);
|
|
await user.clear(websiteInput());
|
|
await user.type(websiteInput(), 'https://corrected.example');
|
|
await searchFor(user, STATION);
|
|
|
|
expect(websiteInput().value).toBe('https://corrected.example');
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-066: the ids of the previous place do not travel with the new one', async () => {
|
|
const user = userEvent.setup();
|
|
const onSave = vi.fn();
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
|
|
|
await searchFor(user, { ...AIRPORT, google_place_id: 'ChIJham' });
|
|
await searchFor(user, { ...STATION, osm_id: 'relation:3600565' });
|
|
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.osm_id).toBe('relation:3600565');
|
|
expect(payload.google_place_id).toBeFalsy();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-067: editing a saved place keeps its stored website', async () => {
|
|
// The dangerous direction: the form was filled from the database, so a
|
|
// search result that says nothing about a field must not empty it.
|
|
const user = userEvent.setup();
|
|
render(
|
|
<PlaceFormModal
|
|
{...defaultProps}
|
|
place={{ id: 7, name: 'Old', website: 'https://stored.example', lat: 1, lng: 2 } as never}
|
|
/>,
|
|
);
|
|
expect(websiteInput().value).toBe('https://stored.example');
|
|
|
|
await searchFor(user, STATION);
|
|
expect(websiteInput().value).toBe('https://stored.example');
|
|
});
|
|
});
|
|
|
|
// ── Linked expense (#1298) — the same block bookings have ──────────────────
|
|
|
|
describe('Costs section', () => {
|
|
const withBudget = () => seedStore(useAddonStore, {
|
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
|
loaded: true,
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-068: the block only appears while the Budget addon is on', () => {
|
|
const { unmount } = render(<PlaceFormModal {...defaultProps} />);
|
|
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
|
|
unmount();
|
|
|
|
withBudget();
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-069: creating an expense saves the place first, then opens the editor', async () => {
|
|
withBudget();
|
|
const onSave = vi.fn().mockResolvedValue({ id: 42 });
|
|
const onOpenExpense = vi.fn();
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
|
|
|
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
|
|
|
await waitFor(() => expect(onOpenExpense).toHaveBeenCalled());
|
|
expect(onSave).toHaveBeenCalled();
|
|
expect(onSave.mock.invocationCallOrder[0]).toBeLessThan(onOpenExpense.mock.invocationCallOrder[0]);
|
|
expect(onOpenExpense).toHaveBeenCalledWith({
|
|
prefill: { placeId: 42, name: 'Louvre', category: 'activities' },
|
|
});
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-070: a save that yields no id opens no editor', async () => {
|
|
withBudget();
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
const onOpenExpense = vi.fn();
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
|
|
|
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onOpenExpense).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-070b: a nameless click drops the intent instead of arming the next save', async () => {
|
|
withBudget();
|
|
const onSave = vi.fn().mockResolvedValue({ id: 42 });
|
|
const onOpenExpense = vi.fn();
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
|
|
|
// No name yet — the save is refused and the intent must not survive it.
|
|
fireEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
|
await waitFor(() => expect(onSave).not.toHaveBeenCalled());
|
|
|
|
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onOpenExpense).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-071: an already-linked expense is shown with its amount instead of the button', () => {
|
|
withBudget();
|
|
seedStore(useTripStore, {
|
|
trip: buildTrip({ id: 1 }),
|
|
budgetItems: [{ id: 8, trip_id: 1, name: 'Louvre tickets', total_price: 34, category: 'activities', place_id: 7 }],
|
|
});
|
|
render(<PlaceFormModal {...defaultProps} place={{ id: 7, name: 'Louvre' } as never} />);
|
|
|
|
expect(screen.getByText('Louvre tickets')).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-072: an expense linked to another place is not claimed', () => {
|
|
withBudget();
|
|
seedStore(useTripStore, {
|
|
trip: buildTrip({ id: 1 }),
|
|
budgetItems: [{ id: 8, trip_id: 1, name: 'Orsay tickets', total_price: 16, category: 'activities', place_id: 99 }],
|
|
});
|
|
render(<PlaceFormModal {...defaultProps} place={{ id: 7, name: 'Louvre' } as never} />);
|
|
|
|
expect(screen.queryByText('Orsay tickets')).not.toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── The road trip's own use of this form: a service stop added by hand ────────
|
|
|
|
/**
|
|
* FE-PLANNER-PLACEFORM-073 to -080.
|
|
*
|
|
* The corridor search reads OpenStreetMap and misses a good share of the chargers
|
|
* actually standing at a junction, so "add manually" opens THIS form rather than a
|
|
* bespoke one: its typed-ahead search is the one the rest of TREK uses. Three things
|
|
* change and nothing else does, which is what the first two tests here pin.
|
|
*
|
|
* Where the stop goes cannot be decided when the dialog opens, because nothing has been
|
|
* chosen yet. It is worked out at the save, from the coordinates being saved.
|
|
*/
|
|
describe('PlaceFormModal as a road trip service stop', () => {
|
|
type Mode = NonNullable<React.ComponentProps<typeof PlaceFormModal>['serviceStop']>;
|
|
|
|
const serviceStop = (over: Partial<Mode> = {}): Mode => ({
|
|
days: [
|
|
{ dayId: 5, dayNumber: 1, stops: ['Hamburg', 'Bremen', 'Berlin'] },
|
|
{ dayId: 6, dayNumber: 2, stops: ['Berlin', 'Dresden'] },
|
|
],
|
|
appendDay: { dayId: 5, dayNumber: 1, position: 3 },
|
|
// Everything east of the twelfth meridian falls on the second day's leg here; the
|
|
// real projection is the planner's and has its own suite.
|
|
targetFor: (_lat: number, lng: number) =>
|
|
lng > 12 ? { dayId: 6, position: 1, offRouteKm: 0.4 } : { dayId: 5, position: 1, offRouteKm: 0.2 },
|
|
...over,
|
|
});
|
|
|
|
const withBudgetAddon = () => seedStore(useAddonStore, {
|
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
|
loaded: true,
|
|
});
|
|
|
|
const typeCoords = (lat: string, lng: string) => {
|
|
fireEvent.change(screen.getByPlaceholderText(/Latitude/i), { target: { value: lat } });
|
|
fireEvent.change(screen.getByPlaceholderText(/Longitude/i), { target: { value: lng } });
|
|
};
|
|
|
|
const named = (name: string) => {
|
|
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: name } });
|
|
};
|
|
|
|
it('FE-PLANNER-PLACEFORM-073: the ordinary add place keeps its category and its costs', () => {
|
|
withBudgetAddon();
|
|
render(<PlaceFormModal {...defaultProps} />);
|
|
|
|
expect(screen.getByText('Category')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
|
expect(screen.queryByText('Kind of stop')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-074: a service stop swaps the category for the kind and drops the costs', () => {
|
|
withBudgetAddon();
|
|
render(<PlaceFormModal {...defaultProps} serviceStop={serviceStop()} />);
|
|
|
|
expect(screen.getByText('Kind of stop')).toBeInTheDocument();
|
|
expect(screen.getByText('Time at this stop')).toBeInTheDocument();
|
|
// Nothing has been chosen yet, so the row that asks where on the drive it goes says
|
|
// what it needs first. It turns into the list of legs the moment a place arrives.
|
|
expect(screen.getByTestId('service-stop-needs-point')).toBeInTheDocument();
|
|
// A petrol stop is not an activity with a budget line, and it is not a taste either.
|
|
expect(screen.queryByText('Category')).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-075: it opens on a kind rather than on none, and says which dialog it is', () => {
|
|
render(<PlaceFormModal {...defaultProps} serviceStop={serviceStop()} />);
|
|
|
|
// A stop left without a kind is a numbered destination that counts in every total,
|
|
// which is the very thing this path exists to avoid.
|
|
expect(screen.getByRole('button', { name: 'Fuel' })).toHaveAttribute('aria-pressed', 'true');
|
|
expect(screen.getByRole('button', { name: /10 min/ })).toHaveAttribute('aria-pressed', 'true');
|
|
expect(screen.getAllByText('Add as a stop').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-076: picking a kind also picks how long that kind takes', async () => {
|
|
const onSave = vi.fn().mockResolvedValue({ id: 9 });
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} serviceStop={serviceStop()} />);
|
|
|
|
named('Supercharger');
|
|
fireEvent.click(screen.getByRole('button', { name: 'Charging' }));
|
|
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
|
stop_type: 'charging',
|
|
duration_minutes: 30,
|
|
category_id: null,
|
|
}));
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-077: where it goes is worked out from the coordinates that are saved', async () => {
|
|
const onSave = vi.fn().mockResolvedValue({ id: 9 });
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} serviceStop={serviceStop()} />);
|
|
|
|
// The form opened on nothing at all. These coordinates arrive afterwards, and they
|
|
// are what the position has to follow.
|
|
named('Supercharger');
|
|
typeCoords('51.05', '13.73');
|
|
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
|
_serviceStop: { dayId: 6, position: 1, offRouteKm: 0.4 },
|
|
}));
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-078: the leg the traveller overrules is the one that lands', async () => {
|
|
const onSave = vi.fn().mockResolvedValue({ id: 9 });
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} serviceStop={serviceStop()} />);
|
|
|
|
named('Supercharger');
|
|
typeCoords('51.05', '13.73');
|
|
fireEvent.click(screen.getByRole('button', { name: /Berlin · Dresden/ }));
|
|
fireEvent.click(screen.getByText(/Bremen · Berlin/));
|
|
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
|
// Overruled onto another stretch, so the projection's distance does not come
|
|
// along: it was measured against the leg the drive guessed, not this one.
|
|
_serviceStop: { dayId: 5, position: 2, offRouteKm: null },
|
|
}));
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-079: a place far off the drawn line is noted, never refused', async () => {
|
|
const onSave = vi.fn().mockResolvedValue({ id: 9 });
|
|
const far = serviceStop({ targetFor: () => ({ dayId: 5, position: 2, offRouteKm: 18.3 }) });
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} serviceStop={far} />);
|
|
|
|
named('Supercharger');
|
|
typeCoords('41.9', '12.5');
|
|
expect(screen.getByText(/check which leg it belongs on/)).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
|
_serviceStop: { dayId: 5, position: 2, offRouteKm: 18.3 },
|
|
}));
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-081: a stop with no coordinates is not filed onto a leg by guesswork', async () => {
|
|
const onSave = vi.fn().mockResolvedValue({ id: 9 });
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} serviceStop={serviceStop()} />);
|
|
|
|
// The premise case: the charger is in no index, so the traveller types the name. It
|
|
// cannot be measured onto a road, and picking a leg for it anyway used to file it as
|
|
// the first stop of the trip's first day without a word.
|
|
named('Supercharger Dammer Berge');
|
|
expect(screen.getByTestId('service-stop-needs-point')).toBeInTheDocument();
|
|
expect(screen.queryByText('Add between')).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ _serviceStop: null }));
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-082: a second stop of the same brand saves on the first press', async () => {
|
|
// Four hundred kilometres apart and both called Aral, which on a motorway is the
|
|
// normal case and is exactly what this path is for. The name check used to refuse
|
|
// the first press and demand a second.
|
|
seedStore(useTripStore, {
|
|
trip: buildTrip({ id: 1 }),
|
|
places: [buildPlace({ id: 3, name: 'Aral', lat: 53.1, lng: 8.8 })],
|
|
});
|
|
const onSave = vi.fn().mockResolvedValue({ id: 9 });
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} serviceStop={serviceStop()} />);
|
|
|
|
named('Aral');
|
|
typeCoords('51.05', '13.73');
|
|
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
|
expect(screen.queryByRole('button', { name: /Add anyway/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-083: the same pump twice over is noted beside the name, not refused', async () => {
|
|
seedStore(useTripStore, {
|
|
trip: buildTrip({ id: 1 }),
|
|
places: [buildPlace({ id: 3, name: 'Aral Dammer Berge', lat: 52.5, lng: 8.2 })],
|
|
});
|
|
const onSave = vi.fn().mockResolvedValue({ id: 9 });
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} serviceStop={serviceStop()} />);
|
|
|
|
named('Tankstelle');
|
|
typeCoords('52.5', '8.2');
|
|
|
|
// The same place standing at the same coordinates is worth saying. It is a note
|
|
// while the form is being filled, and the press that saves still saves.
|
|
expect(screen.getByText(/is already on this trip/)).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-084: an expense intent left over from a refused save never reaches this form', async () => {
|
|
withBudgetAddon();
|
|
const refused = vi.fn().mockRejectedValue(new Error('Server error'));
|
|
const onOpenExpense = vi.fn();
|
|
const { rerender } = render(
|
|
<PlaceFormModal {...defaultProps} onSave={refused} onOpenExpense={onOpenExpense} />,
|
|
);
|
|
|
|
// An ordinary add place, asked for an expense, that the server refuses.
|
|
named('Louvre');
|
|
fireEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
|
await waitFor(() => expect(refused).toHaveBeenCalled());
|
|
expect(onOpenExpense).not.toHaveBeenCalled();
|
|
|
|
const saved = vi.fn().mockResolvedValue({ id: 9 });
|
|
rerender(<PlaceFormModal {...defaultProps} isOpen={false} onSave={saved} onOpenExpense={onOpenExpense} />);
|
|
rerender(
|
|
<PlaceFormModal
|
|
{...defaultProps}
|
|
onSave={saved}
|
|
onOpenExpense={onOpenExpense}
|
|
serviceStop={serviceStop()}
|
|
/>,
|
|
);
|
|
|
|
named('Supercharger');
|
|
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
// The one mode whose contract is that costs are gone: an editor opening here would
|
|
// file a petrol stop as an activity nobody asked to pay for.
|
|
await waitFor(() => expect(saved).toHaveBeenCalled());
|
|
expect(onOpenExpense).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-PLANNER-PLACEFORM-080: with nothing routed it appends to the day the panel is on', async () => {
|
|
const onSave = vi.fn().mockResolvedValue({ id: 9 });
|
|
const unrouted = serviceStop({ days: [], appendDay: { dayId: 5, dayNumber: 1, position: 2 } });
|
|
render(<PlaceFormModal {...defaultProps} onSave={onSave} serviceStop={unrouted} />);
|
|
|
|
named('Supercharger');
|
|
typeCoords('53.55', '9.99');
|
|
expect(screen.getByText(/it goes at the end of day 1/)).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
|
_serviceStop: { dayId: 5, position: 2, offRouteKm: 0 },
|
|
}));
|
|
});
|
|
});
|