mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(planner): moving a booking onto another day clears the leftover drag data
Dropping a booking on the body of another day moved it but left the
shared drag data (window.__dragData) of an earlier place or map drag in
place, while dropping it on the end zone of the day cleared both. A later
drop with nothing of its own could then read that leftover place id.
The cross day booking branch of the day body drop now clears
window.__dragData along with dragDataRef, the same as the end zone. The
two clears are one chained assignment, so DayPlanSidebar.tsx stays at
the line count lint:size holds it to.
Red test: FE-PLANNER-DAYPLAN-236 in
client/src/components/Planner/DayPlanSidebar.dragData.test.tsx failed
with "expected { placeId: '77' } to be null".
No existing assertions changed.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
// FE-PLANNER-DAYPLAN-236: the drag data a booking drop leaves behind. The mocks
|
||||
// are the ones DayPlanSidebar.test.tsx renders the sidebar with.
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildDay, buildReservation, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore, type TripStoreState } from '../../store/tripStore';
|
||||
import type { Reservation } from '../../types';
|
||||
import DayPlanSidebar from './DayPlanSidebar';
|
||||
|
||||
// ── Hoisted mock state (accessible in vi.mock factories) ────────────────────
|
||||
const mockDayNotesState = vi.hoisted(() => ({
|
||||
noteUi: {} as Record<string, unknown>,
|
||||
dayNotes: {} as Record<string, unknown[]>,
|
||||
setNoteUi: vi.fn(),
|
||||
noteInputRef: { current: null } as { current: null },
|
||||
openAddNote: vi.fn(),
|
||||
openEditNote: vi.fn(),
|
||||
cancelNote: vi.fn(),
|
||||
saveNote: vi.fn(),
|
||||
deleteNote: vi.fn(),
|
||||
moveNote: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Module mocks ────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../api/client')>();
|
||||
return {
|
||||
...actual,
|
||||
assignmentsApi: {
|
||||
reorder: vi.fn().mockResolvedValue({}),
|
||||
remove: vi.fn().mockResolvedValue({}),
|
||||
updateTime: vi.fn().mockResolvedValue({}),
|
||||
updateTransport: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
reservationsApi: {
|
||||
list: vi.fn().mockResolvedValue({ reservations: [] }),
|
||||
updatePositions: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
daysApi: {
|
||||
...actual.daysApi,
|
||||
updateTransport: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
vi.mock('../Map/RouteCalculator', () => ({
|
||||
calculateRoute: vi.fn().mockResolvedValue({ distanceText: '5 km', durationText: '1h', coordinates: [] }),
|
||||
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
|
||||
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
|
||||
optimizeRoute: vi.fn().mockImplementation((places) => places),
|
||||
// One leg per waypoint gap; the connector between two stops reads distanceText.
|
||||
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) =>
|
||||
Promise.resolve({
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
drivingText: '10 min',
|
||||
walkingText: '25 min',
|
||||
})),
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
// PlaceAvatar needs IntersectionObserver
|
||||
class MockIO {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
}
|
||||
beforeAll(() => {
|
||||
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = MockIO;
|
||||
});
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
fetchPhoto: vi.fn(),
|
||||
onThumbReady: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useDayNotes', () => ({
|
||||
useDayNotes: () => mockDayNotesState,
|
||||
}));
|
||||
|
||||
vi.mock('../Weather/WeatherWidget', () => ({
|
||||
default: (props: { locationName?: string | null }) => (
|
||||
<span data-testid="weather-widget" data-location={props.locationName ?? ''} />
|
||||
),
|
||||
}));
|
||||
|
||||
// A stable toast object so tests can assert on the messages the sidebar raises.
|
||||
const mockToast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||
|
||||
vi.mock('../shared/Toast', () => ({
|
||||
useToast: () => mockToast,
|
||||
}));
|
||||
|
||||
vi.mock('../../store/permissionsStore', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../store/permissionsStore')>()),
|
||||
useCanDo: () => () => true,
|
||||
}));
|
||||
|
||||
const trip = buildTrip({ id: 1, currency: 'EUR' });
|
||||
|
||||
function makeDefaultProps(overrides = {}) {
|
||||
return {
|
||||
tripId: 1,
|
||||
trip,
|
||||
days: [],
|
||||
places: [],
|
||||
categories: [],
|
||||
assignments: {},
|
||||
selectedDayId: null,
|
||||
selectedPlaceId: null,
|
||||
selectedAssignmentId: null,
|
||||
onSelectDay: vi.fn(),
|
||||
onPlaceClick: vi.fn(),
|
||||
onDayDetail: vi.fn(),
|
||||
accommodations: [],
|
||||
onReorder: vi.fn(),
|
||||
onUpdateDayTitle: vi.fn(),
|
||||
onRouteCalculated: vi.fn(),
|
||||
onAssignToDay: vi.fn(),
|
||||
onRemoveAssignment: vi.fn(),
|
||||
onEditPlace: vi.fn(),
|
||||
onDeletePlace: vi.fn(),
|
||||
reservations: [],
|
||||
onAddReservation: vi.fn(),
|
||||
onNavigateToFiles: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
Element.prototype.scrollTo = vi.fn();
|
||||
clearExchangeRateCache();
|
||||
server.use(http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([])));
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.__dragData = null;
|
||||
});
|
||||
|
||||
describe('DayPlanSidebar drag data', () => {
|
||||
it('FE-PLANNER-DAYPLAN-236: a booking moved to another day body clears the shared drag data, as the end zone does', () => {
|
||||
const updateReservation = vi.fn(async () => ({}) as Reservation);
|
||||
useTripStore.setState({ updateReservation } as unknown as Partial<TripStoreState>);
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
|
||||
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
|
||||
];
|
||||
const train = buildReservation({
|
||||
id: 402,
|
||||
type: 'train',
|
||||
title: 'Night train',
|
||||
day_id: 10,
|
||||
reservation_time: '2025-06-01T22:00:00',
|
||||
});
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [train] })} />);
|
||||
// Left behind by an earlier drag from the place list or the map.
|
||||
window.__dragData = { placeId: '77' };
|
||||
const row = screen.getByText('Night train').closest('[draggable="true"]') as HTMLElement;
|
||||
fireEvent.dragStart(row, { dataTransfer: { setData: vi.fn(), effectAllowed: '', getData: vi.fn(() => '') } });
|
||||
fireEvent.drop(document.querySelectorAll('[data-dp="day-body"]')[1], {
|
||||
dataTransfer: { getData: vi.fn(() => '') },
|
||||
});
|
||||
expect(updateReservation).toHaveBeenCalledWith(1, 402, { day_id: 11, end_day_id: 11 });
|
||||
expect(window.__dragData).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1839,9 +1839,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
setDraggingId(null); setDropTargetKey(null); window.__dragData = dragDataRef.current = null; return
|
||||
}
|
||||
if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (!assignmentId && !noteId && !placeId) { window.__dragData = dragDataRef.current = null; return }
|
||||
if (placeId) {
|
||||
onAssignToDay?.(Number.parseInt(placeId), day.id)
|
||||
setDropTargetKey(null); window.__dragData = null; return
|
||||
|
||||
Reference in New Issue
Block a user