fix(planner): booking codes stay blurred in the edit forms too

With "Blur booking codes" on, the bookings list and the transport details
hid a code, but opening the same booking to edit it printed the code in
the clear: the reservation and transport forms with every per-segment
code of a flight or train, the hotel editor in the day panel and the
three phone sheets. The stay's own code in the day panel, the booking
strip in the place inspector and the booking lines under a stay and a
stop on the phone showed it plainly as well.

One shared BookingCodeInput and BlurredCode now carry the setting to all
of them. A filled code field stays blurred until it is hovered or
focused, so clicking, tapping or tabbing into it still reveals the code
for editing, and the value is saved exactly as typed. With the setting
off nothing changes, and the PDF export and the share pages stay as
they were.
This commit is contained in:
Maurice
2026-09-22 21:09:25 +02:00
parent 93dddaa4af
commit bb515ac3cc
22 changed files with 769 additions and 35 deletions
@@ -1,4 +1,4 @@
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-080
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-084
import React from 'react';
import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -12,6 +12,7 @@ import { usePluginStore } from '../../store/pluginStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import DayDetailPanel from './DayDetailPanel';
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
@@ -1896,3 +1897,80 @@ describe('DayDetailPanel time format', () => {
expect(screen.getByText('11:00')).toBeInTheDocument();
});
});
// ── Blur booking codes: the stay's own code and the hotel editor (#2457) ────
describe('DayDetailPanel blur booking codes (#2457)', () => {
beforeEach(() => {
server.use(http.get('/api/view-contributions/:view/:tripId', () => HttpResponse.json({ contributions: [] })));
});
const blurOn = (on: boolean) => seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: on },
});
const stayWithCode = (confirmation: string) => server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Code Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation,
}],
})
),
);
const openHotelEditor = async () => {
await screen.findByText('Code Hotel');
// header collapse (0), header close (1), pencil (2), remove (3), as in DAYDETAIL-032
await userEvent.click(screen.getAllByRole('button')[2]);
await waitFor(() => {
expect(document.body.querySelector('[style*="z-index: 99999"]')?.textContent).toMatch(/Edit accommodation/i);
});
};
it('FE-PLANNER-DAYDETAIL-081: the confirmation code of the stay itself is blurred in the details grid', async () => {
blurOn(true);
stayWithCode('HOTEL-SECRET');
render(<DayDetailPanel {...defaultProps} />);
const code = await screen.findByText('HOTEL-SECRET');
expect(isBlurred(code)).toBe(true);
});
it('FE-PLANNER-DAYDETAIL-082: the confirmation field of the hotel editor is blurred until focused', async () => {
blurOn(true);
stayWithCode('HOTEL-SECRET');
render(<DayDetailPanel {...defaultProps} />);
await openHotelEditor();
const field = screen.getByDisplayValue('HOTEL-SECRET') as HTMLInputElement;
expect(isBlurred(field)).toBe(true);
act(() => field.focus());
expect(isBlurred(field)).toBe(false);
act(() => field.blur());
expect(isBlurred(field)).toBe(true);
});
it('FE-PLANNER-DAYDETAIL-083: with the setting off the stay code and the editor field stay plain', async () => {
blurOn(false);
stayWithCode('HOTEL-PLAIN');
render(<DayDetailPanel {...defaultProps} />);
expect(isBlurred(await screen.findByText('HOTEL-PLAIN'))).toBe(false);
await openHotelEditor();
expect(isBlurred(screen.getByDisplayValue('HOTEL-PLAIN'))).toBe(false);
});
it('FE-PLANNER-DAYDETAIL-084: a blurred code in the hotel editor still saves unchanged', async () => {
blurOn(true);
let body: Record<string, unknown> | null = null;
stayWithCode('HOTEL-SECRET');
server.use(
http.put('/api/trips/1/accommodations/1', async ({ request }) => {
body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ accommodation: { id: 1, place_id: 5, place_name: 'Code Hotel', start_day_id: 1, end_day_id: 3, confirmation: 'HOTEL-SECRET' } });
}),
);
render(<DayDetailPanel {...defaultProps} places={[buildPlace({ id: 5, name: 'Code Hotel' })]} />);
await openHotelEditor();
await userEvent.click(screen.getByText(/Save/i));
await waitFor(() => expect(body).not.toBeNull());
expect(body!.confirmation).toBe('HOTEL-SECRET');
});
});
@@ -13,6 +13,7 @@ import { useTripStore } from '../../store/tripStore'
import { applyStayStops } from '../../store/stayStops'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
import { BlurredCode, BookingCodeInput } from '../shared/BookingCode'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
@@ -453,7 +454,7 @@ function AccommodationList({ dayAccommodations, day, reservations, canEditDays,
)}
{acc.confirmation && (
<div style={{ flex: 1, padding: '8px 10px', textAlign: 'center' }}>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{acc.confirmation}</div>
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}><BlurredCode>{acc.confirmation}</BlurredCode></div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
<Hash size={8} /> {t('day.confirmation')}
</div>
@@ -632,7 +633,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
</div>
<div style={{ flex: 2, minWidth: 120 }}>
<label style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.confirmation')}</label>
<input type="text" value={hotelForm.confirmation} onChange={e => setHotelForm(f => ({ ...f, confirmation: e.target.value }))}
<BookingCodeInput value={hotelForm.confirmation} onChange={e => setHotelForm(f => ({ ...f, confirmation: e.target.value }))}
placeholder="ABC-12345" style={{ width: '100%', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', boxSizing: 'border-box', height: 38 }} />
</div>
</div>
@@ -12,6 +12,7 @@ import { usePermissionsStore } from '../../store/permissionsStore';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import type { AssignmentsMap } from '../../types';
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
// ── Module mocks ──────────────────────────────────────────────────────────────
@@ -1565,3 +1566,25 @@ describe('PlaceInspector', () => {
expect(screen.queryByText('Remove from Day')).toBeNull();
});
})
// ── Blur booking codes on the linked-booking strip (#2457) ────────────────────
describe('PlaceInspector blur booking codes (#2457)', () => {
const renderWithBooking = (blur: boolean) => {
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: blur } });
const res = { ...buildReservation({ id: 3, title: 'Dinner at Jules Verne', status: 'confirmed', confirmation_number: 'TABLE-SECRET' }), assignment_id: 9 };
render(<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={9}
assignments={{ '1': [{ id: 9, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
reservations={[res]} onEditReservation={vi.fn()} />);
};
it('FE-PLANNER-INSPECTOR-103: the booking code on the linked-booking strip is blurred while the setting is on', () => {
renderWithBooking(true);
expect(isBlurred(screen.getByText(/TABLE-SECRET/))).toBe(true);
});
it('FE-PLANNER-INSPECTOR-104: with the setting off the strip shows the code plainly', () => {
renderWithBooking(false);
expect(isBlurred(screen.getByText(/TABLE-SECRET/))).toBe(false);
});
});
@@ -10,6 +10,7 @@ import { markdownLinkComponents } from '../shared/markdownLink'
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route, StickyNote } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import PlaceAvatarUpload from '../shared/PlaceAvatarUpload'
import { BlurredCode } from '../shared/BookingCode'
import PlaceRating from '../shared/StarRating'
import TrackColorPicker from '../shared/TrackColorPicker'
import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors'
@@ -960,7 +961,7 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
{res.confirmation_number && (
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{res.confirmation_number}</div>
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}><BlurredCode>{res.confirmation_number}</BlurredCode></div>
</div>
)}
</div>
@@ -1,11 +1,13 @@
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-095
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-100
import { render, screen, waitFor, fireEvent, within, act } 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 { useSettingsStore } from '../../store/settingsStore';
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import {
buildUser,
@@ -1566,4 +1568,57 @@ describe('ReservationModal', () => {
expect(times[0].value).toBe('16:00');
expect(times[2].value).toBe('10:30');
});
// ── Blur booking codes in the edit form (#2457) ─────────────────────────────
describe('blur booking codes (#2457)', () => {
const blurOn = (on: boolean) => seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: on } });
it('FE-PLANNER-RESMODAL-096: the booking code field is blurred while the setting is on and the field is not focused', () => {
blurOn(true);
const res = buildReservation({ type: 'restaurant', confirmation_number: 'PNR-SECRET' });
render(<ReservationModal {...defaultProps} reservation={res} />);
const code = screen.getByDisplayValue('PNR-SECRET');
expect(isBlurred(code)).toBe(true);
});
it('FE-PLANNER-RESMODAL-097: a hotel booking hides its code the same way', () => {
blurOn(true);
const res = buildReservation({ type: 'hotel', title: 'Hotel Adlon', confirmation_number: 'HOTEL-SECRET' });
render(<ReservationModal {...defaultProps} reservation={res} />);
expect(isBlurred(screen.getByDisplayValue('HOTEL-SECRET'))).toBe(true);
});
it('FE-PLANNER-RESMODAL-098: focusing the field reveals the code for editing, leaving it hides it again', () => {
blurOn(true);
const res = buildReservation({ type: 'restaurant', confirmation_number: 'PNR-SECRET' });
render(<ReservationModal {...defaultProps} reservation={res} />);
const code = screen.getByDisplayValue('PNR-SECRET') as HTMLInputElement;
expect(isBlurred(code)).toBe(true);
act(() => code.focus());
expect(isBlurred(code)).toBe(false);
act(() => code.blur());
expect(isBlurred(code)).toBe(true);
});
it('FE-PLANNER-RESMODAL-099: with the setting off the code stays plain', () => {
blurOn(false);
const res = buildReservation({ type: 'restaurant', confirmation_number: 'PNR-PLAIN' });
render(<ReservationModal {...defaultProps} reservation={res} />);
expect(isBlurred(screen.getByDisplayValue('PNR-PLAIN'))).toBe(false);
});
it('FE-PLANNER-RESMODAL-100: a blurred code still saves unchanged, and an edit typed into it is kept', async () => {
blurOn(true);
const onSave = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ type: 'restaurant', title: 'Dinner', confirmation_number: 'PNR-SECRET' });
render(<ReservationModal {...defaultProps} onSave={onSave} reservation={res} />);
const code = screen.getByDisplayValue('PNR-SECRET');
await userEvent.clear(code);
await userEvent.type(code, 'PNR-NEW');
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].confirmation_number).toBe('PNR-NEW');
});
});
});
@@ -6,6 +6,7 @@ import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { BookingCodeInput } from '../shared/BookingCode'
import { buildAssignmentOptions } from './assignmentOptions'
import AddressInput from './AddressInput'
import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2, ParkingSquare } from 'lucide-react'
@@ -559,7 +560,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
<BookingCodeInput value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
<div>
@@ -1,11 +1,13 @@
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-064
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-070
import { render, screen, waitFor, fireEvent, within, act } 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 { useSettingsStore } from '../../store/settingsStore';
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import {
buildUser,
@@ -1357,4 +1359,83 @@ describe('TransportModal', () => {
expect(screen.getByText(/Orsay/)).toBeInTheDocument();
expect(screen.queryByText('Louvre')).not.toBeInTheDocument();
});
// ── Blur booking codes in the edit form (#2457) ─────────────────────────────
describe('blur booking codes (#2457)', () => {
const blurOn = (on: boolean) => seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: on } });
const codeFields = () => screen.getAllByPlaceholderText('e.g. ABC12345') as HTMLInputElement[];
function multiLegTrain(): Reservation {
const res = buildReservation({ id: 32, title: 'Osaka → Kyoto → Nara', type: 'train' });
return Object.assign(res, {
day_id: 10,
end_day_id: 10,
confirmation_number: 'RAIL-BOOK',
metadata: {
legs: [
{ from: 'Osaka', to: 'Kyoto', train_number: 'JR 1', confirmation_number: 'RAIL-LEG1', dep_day_id: 10, dep_time: '08:00', arr_day_id: 10, arr_time: '08:30' },
{ from: 'Kyoto', to: 'Nara', train_number: 'JR 2', confirmation_number: 'RAIL-LEG2', dep_day_id: 10, dep_time: '09:00', arr_day_id: 10, arr_time: '09:45' },
],
},
endpoints: [
{ id: 11, reservation_id: 32, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '08:00' },
{ id: 12, reservation_id: 32, role: 'stop', sequence: 1, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '09:00' },
{ id: 13, reservation_id: 32, role: 'to', sequence: 2, name: 'Nara Station', code: null, lat: 34.68, lng: 135.82, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '09:45' },
],
}) as unknown as Reservation;
}
it('FE-PLANNER-TRANSMODAL-065: the flight booking code field is blurred while the setting is on', () => {
blurOn(true);
const res = buildReservation({ title: 'LH 400', type: 'flight', confirmation_number: 'FLY-SECRET' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(isBlurred(screen.getByDisplayValue('FLY-SECRET'))).toBe(true);
});
it('FE-PLANNER-TRANSMODAL-066: every per-segment code of a stopover flight is blurred as well', () => {
blurOn(true);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} />);
const codes = codeFields();
expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1']);
expect(codes.map(i => isBlurred(i))).toEqual([true, true, true]);
});
it('FE-PLANNER-TRANSMODAL-067: every per-segment code of a multi-leg train is blurred as well', () => {
blurOn(true);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegTrain()} />);
const codes = codeFields();
expect(codes.map(i => i.value)).toEqual(['RAIL-LEG1', 'RAIL-LEG2', 'RAIL-BOOK']);
expect(codes.map(i => isBlurred(i))).toEqual([true, true, true]);
});
it('FE-PLANNER-TRANSMODAL-068: focusing a code field reveals it for editing, leaving it hides it again', () => {
blurOn(true);
const res = buildReservation({ title: 'LH 400', type: 'flight', confirmation_number: 'FLY-SECRET' });
render(<TransportModal {...defaultProps} reservation={res} />);
const code = screen.getByDisplayValue('FLY-SECRET') as HTMLInputElement;
expect(isBlurred(code)).toBe(true);
act(() => code.focus());
expect(isBlurred(code)).toBe(false);
act(() => code.blur());
expect(isBlurred(code)).toBe(true);
});
it('FE-PLANNER-TRANSMODAL-069: with the setting off every code field stays plain', () => {
blurOn(false);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} />);
expect(codeFields().map(i => isBlurred(i))).toEqual([false, false, false]);
});
it('FE-PLANNER-TRANSMODAL-070: blurred codes still save unchanged', async () => {
blurOn(true);
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
expect(payload.confirmation_number).toBe('BOOK1');
});
});
});
@@ -3,6 +3,7 @@ import { useParams } from 'react-router'
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { BookingCodeInput } from '../shared/BookingCode'
import CustomTimePicker from '../shared/CustomTimePicker'
import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect'
@@ -903,7 +904,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
{writesFlightLegs && (
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
<BookingCodeInput value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
)}
@@ -984,7 +985,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
{writesTrainLegs && (
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
<BookingCodeInput value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
)}
@@ -1119,7 +1120,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
<BookingCodeInput value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
<div>