From bb515ac3ccf80c273cf341b13812eb8d1d30e9d3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 22 Sep 2026 21:09:25 +0200 Subject: [PATCH] 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. --- .../Planner/DayDetailPanel.test.tsx | 80 ++++++++- .../src/components/Planner/DayDetailPanel.tsx | 5 +- .../Planner/PlaceInspector.test.tsx | 23 +++ .../src/components/Planner/PlaceInspector.tsx | 3 +- .../Planner/ReservationModal.test.tsx | 59 ++++++- .../components/Planner/ReservationModal.tsx | 3 +- .../Planner/TransportModal.test.tsx | 85 +++++++++- .../src/components/Planner/TransportModal.tsx | 7 +- .../components/shared/BookingCode.test.tsx | 156 ++++++++++++++++++ client/src/components/shared/BookingCode.tsx | 79 +++++++++ client/src/hooks/useBlurBookingCodes.ts | 12 ++ .../screens/trip/plan/MPlanTimelineRows.tsx | 23 ++- .../trip/sheets/MAccommodationSheet.tsx | 4 +- .../mobile/screens/trip/sheets/MDaySheet.tsx | 4 +- .../screens/trip/sheets/MReservationSheet.tsx | 4 +- .../trip/sheets/MTransportFormSheet.tsx | 10 +- client/tests/helpers/bookingCodeBlur.ts | 24 +++ .../mobile/trip/MAccommodationSheet.test.tsx | 45 ++++- .../tests/unit/mobile/trip/MDaySheet.test.tsx | 21 ++- .../mobile/trip/MPlanTimelineRows.test.tsx | 43 ++++- .../mobile/trip/MReservationSheet.test.tsx | 48 +++++- .../mobile/trip/MTransportFormSheet.test.tsx | 66 +++++++- 22 files changed, 769 insertions(+), 35 deletions(-) create mode 100644 client/src/components/shared/BookingCode.test.tsx create mode 100644 client/src/components/shared/BookingCode.tsx create mode 100644 client/src/hooks/useBlurBookingCodes.ts create mode 100644 client/tests/helpers/bookingCodeBlur.ts diff --git a/client/src/components/Planner/DayDetailPanel.test.tsx b/client/src/components/Planner/DayDetailPanel.test.tsx index 564ec67cc..9be8ad13d 100644 --- a/client/src/components/Planner/DayDetailPanel.test.tsx +++ b/client/src/components/Planner/DayDetailPanel.test.tsx @@ -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(); + 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(); + 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(); + 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 | null = null; + stayWithCode('HOTEL-SECRET'); + server.use( + http.put('/api/trips/1/accommodations/1', async ({ request }) => { + body = await request.json() as Record; + 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(); + await openHotelEditor(); + await userEvent.click(screen.getByText(/Save/i)); + await waitFor(() => expect(body).not.toBeNull()); + expect(body!.confirmation).toBe('HOTEL-SECRET'); + }); +}); diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index b1c4e0f7f..439f66fbc 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -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 && (
-
{acc.confirmation}
+
{acc.confirmation}
{t('day.confirmation')}
@@ -632,7 +633,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
- setHotelForm(f => ({ ...f, confirmation: e.target.value }))} + 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 }} />
diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index facc0317b..be48aeec0 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -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(); + }; + + 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); + }); +}); diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index aed903a36..5ffbc52f0 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -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 && (
{t('reservations.confirmationCode')}
-
{res.confirmation_number}
+
{res.confirmation_number}
)} diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index b129bea73..e5927db5a 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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'); + }); + }); }); diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 6fb092ffa..c7d73bdaf 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -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
- set('confirmation_number', e.target.value)} + set('confirmation_number', e.target.value)} placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index c8c651384..beed29cdd 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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'); + }); + }); }); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 9e2b330c2..270e07aa9 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -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 && (
- updateWp({ confirmation_number: e.target.value })} + updateWp({ confirmation_number: e.target.value })} placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
)} @@ -984,7 +985,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel {writesTrainLegs && (
- updateWp({ confirmation_number: e.target.value })} + updateWp({ confirmation_number: e.target.value })} placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
)} @@ -1119,7 +1120,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
- set('confirmation_number', e.target.value)} + set('confirmation_number', e.target.value)} placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
diff --git a/client/src/components/shared/BookingCode.test.tsx b/client/src/components/shared/BookingCode.test.tsx new file mode 100644 index 000000000..32f1b28ec --- /dev/null +++ b/client/src/components/shared/BookingCode.test.tsx @@ -0,0 +1,156 @@ +// FE-COMP-BOOKCODE-001 to FE-COMP-BOOKCODE-014 +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createRef, useState } from 'react' +import { useSettingsStore } from '../../store/settingsStore' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { isBlurred } from '../../../tests/helpers/bookingCodeBlur' +import { useBlurBookingCodes } from '../../hooks/useBlurBookingCodes' +import { BlurredCode, BookingCodeInput } from './BookingCode' + +const blurOn = (on: boolean) => seedStore(useSettingsStore, { settings: { blur_booking_codes: on } }) + +function Field({ initial = 'PNR-1', ...rest }: { initial?: string; onFocus?: () => void; onBlur?: () => void; className?: string }) { + const [v, setV] = useState(initial) + return setV(e.target.value)} placeholder="ABC-12345" aria-label="code" {...rest} /> +} + +describe('useBlurBookingCodes', () => { + beforeEach(() => resetAllStores()) + + it('FE-COMP-BOOKCODE-001: follows the setting and reads an unset one as off', () => { + seedStore(useSettingsStore, { settings: { blur_booking_codes: undefined } }) + const { result } = renderHook(() => useBlurBookingCodes()) + expect(result.current).toBe(false) + act(() => blurOn(true)) + expect(result.current).toBe(true) + }) +}) + +describe('BookingCodeInput', () => { + beforeEach(() => resetAllStores()) + + it('FE-COMP-BOOKCODE-002: with the setting off the field is a plain text input with the caller classes only', () => { + blurOn(false) + render() + const field = screen.getByLabelText('code') as HTMLInputElement + expect(field.type).toBe('text') + expect(field.className).toBe('w-full border') + expect(isBlurred(field)).toBe(false) + }) + + it('FE-COMP-BOOKCODE-003: without caller classes and the setting off it adds no class attribute at all', () => { + blurOn(false) + render() + expect(screen.getByLabelText('code').hasAttribute('class')).toBe(false) + }) + + it('FE-COMP-BOOKCODE-004: with the setting on a filled field is blurred and keeps the caller classes', () => { + blurOn(true) + render() + const field = screen.getByLabelText('code') as HTMLInputElement + expect(field.value).toBe('PNR-1') + expect(field.className.startsWith('w-full border ')).toBe(true) + expect(isBlurred(field)).toBe(true) + }) + + it('FE-COMP-BOOKCODE-005: focus lifts the blur and leaving the field brings it back', () => { + blurOn(true) + render() + const field = screen.getByLabelText('code') as HTMLInputElement + act(() => field.focus()) + expect(isBlurred(field)).toBe(false) + act(() => field.blur()) + expect(isBlurred(field)).toBe(true) + }) + + it('FE-COMP-BOOKCODE-006: an empty field is never blurred, so its placeholder stays readable', () => { + blurOn(true) + render() + expect(isBlurred(screen.getByPlaceholderText('ABC-12345'))).toBe(false) + }) + + it('FE-COMP-BOOKCODE-007: what is typed into a blurred field is the value, and the caller focus handlers still run', async () => { + blurOn(true) + const onFocus = vi.fn() + const onBlur = vi.fn() + render() + const field = screen.getByLabelText('code') as HTMLInputElement + await userEvent.clear(field) + await userEvent.type(field, 'NEW-2') + expect(field.value).toBe('NEW-2') + expect(onFocus).toHaveBeenCalled() + act(() => field.blur()) + expect(onBlur).toHaveBeenCalled() + expect(isBlurred(field)).toBe(true) + }) + + it('FE-COMP-BOOKCODE-008: the ref reaches the input element', () => { + blurOn(true) + const ref = createRef() + render( {}} />) + expect(ref.current).toBeInstanceOf(HTMLInputElement) + expect(ref.current?.value).toBe('X') + }) +}) + +describe('BlurredCode', () => { + beforeEach(() => resetAllStores()) + + it('FE-COMP-BOOKCODE-009: with the setting off it renders the bare code and no element around it', () => { + blurOn(false) + const { container } = render(
ABC999
) + expect(screen.getByTestId('host').innerHTML).toBe('ABC999') + expect(container.querySelector('button')).toBeNull() + }) + + it('FE-COMP-BOOKCODE-010: with the setting on the code is a blurred toggle that a click reveals and hides again', () => { + blurOn(true) + render(ABC999) + const code = screen.getByRole('button', { name: 'ABC999' }) + expect(code).toHaveAttribute('aria-pressed', 'false') + expect(isBlurred(code)).toBe(true) + fireEvent.click(code) + expect(code).toHaveAttribute('aria-pressed', 'true') + expect(isBlurred(code)).toBe(false) + fireEvent.click(code) + expect(isBlurred(code)).toBe(true) + }) + + it('FE-COMP-BOOKCODE-011: revealing the code does not also trigger the clickable strip around it', () => { + blurOn(true) + const onRowClick = vi.fn() + render(
ABC999
) + fireEvent.click(screen.getByRole('button', { name: 'ABC999' })) + expect(onRowClick).not.toHaveBeenCalled() + }) + + it('FE-COMP-BOOKCODE-012: Enter and Space stay with the code, other keys still reach the strip', () => { + blurOn(true) + const onRowKey = vi.fn() + render(
onRowKey(e.key)}>ABC999
) + const code = screen.getByRole('button', { name: 'ABC999' }) + fireEvent.keyDown(code, { key: 'Enter' }) + fireEvent.keyDown(code, { key: ' ' }) + expect(onRowKey).not.toHaveBeenCalled() + fireEvent.keyDown(code, { key: 'Escape' }) + expect(onRowKey).toHaveBeenCalledWith('Escape') + }) + + it('FE-COMP-BOOKCODE-013: the non-interactive variant is a blurred span with the caller classes, not a button', () => { + blurOn(true) + render() + const code = screen.getByText('#X9') + expect(code.tagName).toBe('SPAN') + expect(code.className.startsWith('font-geist ')).toBe(true) + expect(isBlurred(code)).toBe(true) + expect(screen.getAllByRole('button')).toHaveLength(1) + }) + + it('FE-COMP-BOOKCODE-014: the non-interactive variant is bare text as well once the setting is off', () => { + blurOn(false) + render(Confirmed · #X9) + expect(screen.getByTestId('host').innerHTML).toBe('Confirmed · #X9') + }) +}) diff --git a/client/src/components/shared/BookingCode.tsx b/client/src/components/shared/BookingCode.tsx new file mode 100644 index 000000000..ef8fbc468 --- /dev/null +++ b/client/src/components/shared/BookingCode.tsx @@ -0,0 +1,79 @@ +import { useState, type InputHTMLAttributes, type ReactNode, type Ref } from 'react' +import { useBlurBookingCodes } from '../../hooks/useBlurBookingCodes' + +/** + * The "Blur booking codes" preference (blur_booking_codes) keeps confirmation + * codes unreadable to anyone looking at the screen until the user reaches for + * them. The pieces here carry it to every place that shows or edits a code, so + * a new surface gets the same look and the same way of revealing a code + * without reading the setting on its own. + */ + +// The strength the code boxes in the bookings tabs and the day sheet use. +const BLUR_CLS = 'blur-[4px] select-none' + +const joinCls = (...parts: (string | false | undefined)[]) => parts.filter(Boolean).join(' ') + +type BookingCodeInputProps = Omit, 'type'> & { + ref?: Ref +} + +/** + * The text field of a form that edits a booking code. While the preference is + * on, a filled field stays blurred until the pointer rests on it or it gets + * focus, so the code is still there to read and change the moment the user + * clicks, taps or tabs into it. An empty field keeps its placeholder readable. + * + * Only a class is added: the value, the handlers and the ref pass through + * untouched, so what gets saved is exactly what was typed, and there is no + * wrapper to upset the grid and flex rows the field sits in. It stays a text + * field on purpose; a password field would bring the browser's password + * manager and a different phone keyboard with it. + */ +export function BookingCodeInput({ className, ...props }: BookingCodeInputProps) { + const blur = useBlurBookingCodes() + const hidden = blur && props.value != null && props.value !== '' + return ( + + ) +} + +/** + * A booking code shown as text. With the preference off it renders the code + * and nothing around it, so the markup of the surface stays what it was. + * + * With the preference on, the code becomes a small toggle that lifts on hover + * and on click, Enter or Space, like the codes in the bookings list. Inside a + * row that is a button itself a second button is not allowed, so + * `interactive={false}` gives a plain blurred span there; the booking the row + * opens is where that code can be read. + */ +export function BlurredCode({ children, interactive = true, className }: { + children: ReactNode + interactive?: boolean + className?: string +}) { + const blur = useBlurBookingCodes() + const [revealed, setRevealed] = useState(false) + if (!blur) return <>{children} + if (!interactive) return {children} + return ( + + ) +} diff --git a/client/src/hooks/useBlurBookingCodes.ts b/client/src/hooks/useBlurBookingCodes.ts new file mode 100644 index 000000000..46922fe6c --- /dev/null +++ b/client/src/hooks/useBlurBookingCodes.ts @@ -0,0 +1,12 @@ +import { useSettingsStore } from '../store/settingsStore' + +/** + * Whether booking codes are drawn blurred for the signed-in user (the + * "Blur booking codes" preference, blur_booking_codes). An unset preference + * reads as off. The pieces in `components/shared/BookingCode` build on it; + * reach for it directly only where a surface has to lay out a code + * differently while it is hidden. + */ +export function useBlurBookingCodes(): boolean { + return useSettingsStore(s => !!s.settings.blur_booking_codes) +} diff --git a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx index b86e12e14..803a53cb6 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx @@ -3,6 +3,8 @@ import type { ReactNode, MouseEvent, CSSProperties } from 'react' import PlaceAvatar from '../../../../components/shared/PlaceAvatar' import { getCategoryIcon } from '../../../../components/shared/categoryIcons' import MarkdownText from '../../../../components/shared/MarkdownText' +import { BlurredCode } from '../../../../components/shared/BookingCode' +import { useBlurBookingCodes } from '../../../../hooks/useBlurBookingCodes' import { RES_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants' import { noteSurface } from '../../../../components/Planner/noteSurface' import { getDisplayTimeForDay, getSpanPhase } from '../../../../utils/dayMerge' @@ -113,6 +115,7 @@ function AvatarRing({ children, className = '', style }: { children: ReactNode; } const TIME_CHIP = 'flex-none whitespace-nowrap rounded-[6px] bg-[color:var(--m-ic)] px-[6px] py-px font-geist text-[0.65625rem] font-semibold' +const ROW_CODE = 'flex-none whitespace-nowrap font-geist text-[0.71875rem] text-m-muted' // ── b3) Place row ──────────────────────────────────────────────────────────── @@ -131,17 +134,23 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re const place = assignment.place const CatIcon = getCategoryIcon(place?.category?.icon) const time = fmtTime(place?.place_time, chrome) + const blurCodes = useBlurBookingCodes() // One line per booking on the stop (#2201). A single one keeps the bare // status line it always had; once there are several, the title tells them // apart, since "Confirmed" twice over says nothing. - const bookingLines = linkedReservations.map(r => ({ - id: r.id, - text: [ + // A code under the blur setting cannot stay inside the line, because the + // first line goes through the Markdown caption and that only takes a string. + // It is kept apart as `code` and drawn blurred right after the line. + const bookingLines = linkedReservations.map(r => { + const code = r.confirmation_number ? `#${r.confirmation_number}` : '' + const parts = [ linkedReservations.length > 1 ? r.title : '', r.status === 'confirmed' ? t('dayplan.confirmed') : t('dayplan.pendingRes'), - r.confirmation_number ? `#${r.confirmation_number}` : '', - ].filter(Boolean).join(' · '), - })) + ] + return blurCodes && code + ? { id: r.id, text: `${parts.filter(Boolean).join(' · ')} ·`, code } + : { id: r.id, text: [...parts, code].filter(Boolean).join(' · '), code: '' } + }) const sub = bookingLines.length > 0 ? bookingLines[0].text : place?.address || place?.description || '' @@ -191,11 +200,13 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
{time && {time}} {sub && {sub}} + {bookingLines[0]?.code && {bookingLines[0].code}}
)} {bookingLines.slice(1).map(line => (
{line.text} + {line.code && {line.code}}
))} {assignment.notes && ( diff --git a/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx b/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx index ed6b22b57..7ce0b31e1 100644 --- a/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx @@ -4,6 +4,7 @@ import MSheet from '../../../components/MSheet' import MIconBtn from '../../../components/MIconBtn' import CustomSelect from '../../../../components/shared/CustomSelect' import CustomTimePicker from '../../../../components/shared/CustomTimePicker' +import { BookingCodeInput } from '../../../../components/shared/BookingCode' import { accommodationsApi } from '../../../../api/client' import { applyStayStops } from '../../../../store/stayStops' import { useTranslation } from '../../../../i18n' @@ -204,8 +205,7 @@ export default function MAccommodationSheet({ planner, shell }: MTripSheetsProps {/* Confirmation */} {t('day.confirmation')} - setForm(f => ({ ...f, confirmation: e.target.value }))} placeholder="ABC-12345" diff --git a/client/src/mobile/screens/trip/sheets/MDaySheet.tsx b/client/src/mobile/screens/trip/sheets/MDaySheet.tsx index 1ad7b52bd..9af5d3b7a 100644 --- a/client/src/mobile/screens/trip/sheets/MDaySheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MDaySheet.tsx @@ -17,6 +17,7 @@ import { getTransportForDay, hasCarrierEndpointOnDay } from '../../../../utils/d import { splitReservationDateTime } from '../../../../utils/formatters' import { dayCoMapsUrl, dayGoogleMapsUrl, optimizeDayOrder } from '../lib/dayRoute' import GoogleMapsIcon from '../../../../components/shared/GoogleMapsIcon' +import { BlurredCode } from '../../../../components/shared/BookingCode' import { splitNoteTime } from '../lib/dayNotes' import { weatherIconFor } from '../plan/planTimelineModel' import type { Assignment, DayNote, Reservation } from '../../../../types' @@ -566,7 +567,8 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) { /> {linked.status === 'confirmed' ? t('reservations.confirmed') : t('reservations.pending')} - {linked.confirmation_number ? ` · #${linked.confirmation_number}` : ''} + {/* The strip is a button already, so the code gets the plain blur, not a toggle. */} + {linked.confirmation_number ? <> · {`#${linked.confirmation_number}`} : ''} )} diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx index 3661e070f..d29902ae5 100644 --- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx @@ -9,6 +9,7 @@ import { typeToCostCategory } from '@trek/shared' import CustomSelect from '../../../../components/shared/CustomSelect' import CustomTimePicker from '../../../../components/shared/CustomTimePicker' import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker' +import { BookingCodeInput } from '../../../../components/shared/BookingCode' import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome' import PlFileAttach from './PlFileAttach' import { buildAssignmentOptions } from '../../../../components/Planner/assignmentOptions' @@ -513,8 +514,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
{t('reservations.confirmationCode')} - set('confirmation_number', e.target.value)} placeholder={t('reservations.confirmationPlaceholder')} diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx index 9d41434d2..8644d3244 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx @@ -8,6 +8,7 @@ import { orderedEndpoints, parseReservationMetadata } from '../../../../utils/fl import { typeToCostCategory } from '@trek/shared' import CustomSelect from '../../../../components/shared/CustomSelect' import CustomTimePicker from '../../../../components/shared/CustomTimePicker' +import { BookingCodeInput } from '../../../../components/shared/BookingCode' import AirportSelect, { type Airport } from '../../../../components/Planner/AirportSelect' import LocationSelect, { type LocationPoint } from '../../../../components/Planner/LocationSelect' import TransitSearchPanel from '../../../../components/Planner/TransitSearchPanel' @@ -809,8 +810,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo {writesFlightLegs && (
{t('reservations.confirmationCode')} - updateWp({ confirmation_number: e.target.value })} placeholder={t('reservations.confirmationPlaceholder')} @@ -899,8 +899,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo {writesTrainLegs && (
{t('reservations.confirmationCode')} - updateWp({ confirmation_number: e.target.value })} placeholder={t('reservations.confirmationPlaceholder')} @@ -1029,8 +1028,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
{t('reservations.confirmationCode')} - set('confirmation_number', e.target.value)} placeholder={t('reservations.confirmationPlaceholder')} diff --git a/client/tests/helpers/bookingCodeBlur.ts b/client/tests/helpers/bookingCodeBlur.ts new file mode 100644 index 000000000..8522f6521 --- /dev/null +++ b/client/tests/helpers/bookingCodeBlur.ts @@ -0,0 +1,24 @@ +/** + * Whether a booking code is hidden by the "Blur booking codes" setting + * (blur_booking_codes) at the moment of the call. + * + * The surfaces hide a code in one of two ways: the older desktop ones set an + * inline `filter: blur(...)`, the rest carry a Tailwind `blur-[...]` class. The + * blur may sit on the element holding the code or on the box right around it, + * so the element and its two nearest ancestors are checked. A focus variant + * (`focus:blur-none`) counts as lifted while the element has focus, because + * that is what the browser would render. + */ +export function isBlurred(el: Element | null): boolean { + let node: Element | null = el + for (let depth = 0; node && depth < 3; depth++, node = node.parentElement) { + const h = node as HTMLElement + if (h.style?.filter?.includes('blur(')) return true + const tokens = (typeof h.className === 'string' ? h.className : '').split(/\s+/) + const blurClass = tokens.some(tk => /^blur(-.+)?$/.test(tk) && tk !== 'blur-none') + const liftedByFocus = h === document.activeElement + && tokens.some(tk => /^focus(-visible|-within)?:(blur-none|filter-none)$/.test(tk)) + if (blurClass && !liftedByFocus) return true + } + return false +} diff --git a/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx b/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx index 51608ec13..e122e06b4 100644 --- a/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx @@ -4,9 +4,11 @@ import MAccommodationSheet from '../../../../src/mobile/screens/trip/sheets/MAcc import type { Accommodation, Category, Day, Place } from '../../../../src/types' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { resetAllStores } from '../../../helpers/store' +import { useSettingsStore } from '../../../../src/store/settingsStore' +import { isBlurred } from '../../../helpers/bookingCodeBlur' import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-ACCSH-001 to FE-MOB-ACCSH-025 +// FE-MOB-ACCSH-001 to FE-MOB-ACCSH-029 // The pickers have their own suites; here they only need to be addressable, so // they render as plain controls. data-value keeps the requested value readable @@ -352,4 +354,45 @@ describe('MAccommodationSheet', () => { expect(shell.openSheet).not.toHaveBeenCalled() expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() }) + + // ── Blur booking codes in the stay editor (#2457) ────────────────────────── + + describe('blur booking codes (#2457)', () => { + /** Both sources the phone reads the preference from: the settings store and planner.settings. */ + function plannerWithBlur(on: boolean) { + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, blur_booking_codes: on } }) + return makePlanner({ + settings: { time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km', blur_booking_codes: on }, + }) + } + const codeField = () => screen.getByPlaceholderText('ABC-12345') as HTMLInputElement + + it('FE-MOB-ACCSH-026: the confirmation field of an edited stay is blurred while the setting is on', () => { + setup(plannerWithBlur(true), makeShell({ dayId: 12, accId: 77 })) + expect(codeField()).toHaveValue('ABC-1') + expect(isBlurred(codeField())).toBe(true) + }) + + it('FE-MOB-ACCSH-027: focusing the field reveals the code for editing, leaving it hides it again', () => { + setup(plannerWithBlur(true), makeShell({ dayId: 12, accId: 77 })) + const field = codeField() + act(() => field.focus()) + expect(isBlurred(field)).toBe(false) + act(() => field.blur()) + expect(isBlurred(field)).toBe(true) + }) + + it('FE-MOB-ACCSH-028: with the setting off the code stays plain', () => { + setup(plannerWithBlur(false), makeShell({ dayId: 12, accId: 77 })) + expect(isBlurred(codeField())).toBe(false) + }) + + it('FE-MOB-ACCSH-029: a blurred code still saves unchanged', async () => { + const update = vi.spyOn(accommodationsApi, 'update').mockResolvedValue({}) + setup(plannerWithBlur(true), makeShell({ dayId: 12, accId: 77 })) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(update).toHaveBeenCalled()) + expect(update.mock.calls[0][2]).toMatchObject({ confirmation: 'ABC-1' }) + }) + }) }) diff --git a/client/tests/unit/mobile/trip/MDaySheet.test.tsx b/client/tests/unit/mobile/trip/MDaySheet.test.tsx index 9340d451c..f3a2582bf 100644 --- a/client/tests/unit/mobile/trip/MDaySheet.test.tsx +++ b/client/tests/unit/mobile/trip/MDaySheet.test.tsx @@ -9,8 +9,9 @@ import { useTripStore } from '../../../../src/store/tripStore' import type { Accommodation, Assignment, Day, DayNote, Reservation } from '../../../../src/types' import { resetAllStores, seedStore } from '../../../helpers/store' import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render' +import { isBlurred } from '../../../helpers/bookingCodeBlur' -// FE-MOB-DAYSH-001 to FE-MOB-DAYSH-028 +// FE-MOB-DAYSH-001 to FE-MOB-DAYSH-030 const DAYS = [ { id: 1, trip_id: 5, day_number: 1, date: '2026-05-01', title: null }, @@ -418,4 +419,22 @@ describe('MDaySheet', () => { await renderSheet(makePlanner({ days, tripAccommodations: [] })) expect(screen.getByRole('dialog', { name: 'Day 2' })).toBeInTheDocument() }) + + // ── Blur booking codes on the stay tile (#2457) ──────────────────────────── + + it('FE-MOB-DAYSH-029: with the blur on, the linked booking code under the stay is blurred like the stay code', async () => { + seedStore(useSettingsStore, { settings: { blur_booking_codes: true } }) + await renderSheet() + // The stay's own code already honours the setting ... + expect(isBlurred(screen.getByText('ABC123'))).toBe(true) + // ... the linked booking's code right below it did not. + expect(isBlurred(screen.getByText(/#X9/))).toBe(true) + }) + + it('FE-MOB-DAYSH-030: with the blur off both codes stay plain', async () => { + seedStore(useSettingsStore, { settings: { blur_booking_codes: false } }) + await renderSheet() + expect(isBlurred(screen.getByText('ABC123'))).toBe(false) + expect(isBlurred(screen.getByText(/#X9/))).toBe(false) + }) }) diff --git a/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx b/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx index 551a2e5f0..1b473ae84 100644 --- a/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx +++ b/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen } from '../../../helpers/render' +import { useSettingsStore } from '../../../../src/store/settingsStore' +import { isBlurred } from '../../../helpers/bookingCodeBlur' import { ConnRow, HotelConnRow, NoteRow, PlaceRow, PlanScheduleRow, ReorderStack, TransitRow, TransportRow, } from '../../../../src/mobile/screens/trip/plan/MPlanTimelineRows' @@ -7,7 +9,7 @@ import type { TransitMeta, TransportEntry } from '../../../../src/mobile/screens import type { PluginDayScheduleItem } from '../../../../src/api/client' import type { Assignment, DayNote, Place, Reservation, RouteSegment, TranslationFn } from '../../../../src/types' -// FE-MOB-PLROW-001 to FE-MOB-PLROW-041 +// FE-MOB-PLROW-001 to FE-MOB-PLROW-044 // Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy. const t: TranslationFn = (key, params) => @@ -531,3 +533,42 @@ describe('NoteRow', () => { expect(onEdit).toHaveBeenCalledTimes(1) }) }) + +// ── Blur booking codes on the plan row (#2457) ──────────────────────────────── + +describe('PlaceRow blur booking codes (#2457)', () => { + const props = { + assignment: assignment(), + fullPlace: undefined, + chrome: chrome(), + reorder: REORDER, + onOpen: vi.fn(), + onEdit: vi.fn(), + onRemove: vi.fn(), + } + const blurOn = (on: boolean) => + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, blur_booking_codes: on } }) + + it('FE-MOB-PLROW-042: the booking code in the row subtitle is blurred while the setting is on', () => { + blurOn(true) + const linkedRes = { id: 31, status: 'confirmed', confirmation_number: 'ROW-SECRET' } as unknown as Reservation + render() + expect(isBlurred(screen.getByText(/ROW-SECRET/))).toBe(true) + }) + + it('FE-MOB-PLROW-043: the extra booking lines of a stop with several bookings blur their codes too', () => { + blurOn(true) + const first = { id: 31, title: 'Lunch', status: 'confirmed', confirmation_number: 'ROW-ONE' } as unknown as Reservation + const second = { id: 32, title: 'Tour', status: 'pending', confirmation_number: 'ROW-TWO' } as unknown as Reservation + render() + expect(isBlurred(screen.getByText(/ROW-ONE/))).toBe(true) + expect(isBlurred(screen.getByText(/ROW-TWO/))).toBe(true) + }) + + it('FE-MOB-PLROW-044: with the setting off the code stays plain', () => { + blurOn(false) + const linkedRes = { id: 31, status: 'confirmed', confirmation_number: 'ROW-PLAIN' } as unknown as Reservation + render() + expect(isBlurred(screen.getByText(/ROW-PLAIN/))).toBe(false) + }) +}) diff --git a/client/tests/unit/mobile/trip/MReservationSheet.test.tsx b/client/tests/unit/mobile/trip/MReservationSheet.test.tsx index 5b43cdd5f..f2bcce499 100644 --- a/client/tests/unit/mobile/trip/MReservationSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MReservationSheet.test.tsx @@ -2,12 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import MReservationSheet from '../../../../src/mobile/screens/trip/sheets/MReservationSheet' import { useAddonStore } from '../../../../src/store/addonStore' import { useTripStore } from '../../../../src/store/tripStore' +import { useSettingsStore } from '../../../../src/store/settingsStore' +import { isBlurred } from '../../../helpers/bookingCodeBlur' import type { Accommodation, Assignment, Day, Place, Reservation, TripMember } from '../../../../src/types' import { buildPlanner } from '../../../helpers/mobileTrip' import { resetAllStores } from '../../../helpers/store' -import { fireEvent, render, screen, waitFor } from '../../../helpers/render' +import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-RESSH-001 to FE-MOB-RESSH-051 +// FE-MOB-RESSH-001 to FE-MOB-RESSH-061 // Date/time/select pickers have their own suites — here they only have to be // addressable, so they render as plain controls. @@ -807,4 +809,46 @@ describe('MReservationSheet', () => { expect(titleField()).toHaveValue('Cafe Central') expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Manual 3') }) + + // ── Blur booking codes in the edit sheet (#2457) ─────────────────────────── + + describe('blur booking codes (#2457)', () => { + /** Both sources the phone reads the preference from: the settings store and planner.settings. */ + function plannerWithBlur(on: boolean, overrides: Record = {}) { + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, blur_booking_codes: on } }) + return makePlanner({ + settings: { time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km', blur_booking_codes: on }, + ...overrides, + }) + } + const codeField = () => screen.getByPlaceholderText('reservations.confirmationPlaceholder') as HTMLInputElement + + it('FE-MOB-RESSH-058: the booking code field is blurred while the setting is on', () => { + setup(plannerWithBlur(true, { editingReservation: DINNER })) + expect(codeField()).toHaveValue('C1') + expect(isBlurred(codeField())).toBe(true) + }) + + it('FE-MOB-RESSH-059: focusing the field reveals the code for editing, leaving it hides it again', () => { + setup(plannerWithBlur(true, { editingReservation: DINNER })) + const field = codeField() + expect(isBlurred(field)).toBe(true) + act(() => field.focus()) + expect(isBlurred(field)).toBe(false) + act(() => field.blur()) + expect(isBlurred(field)).toBe(true) + }) + + it('FE-MOB-RESSH-060: with the setting off the code stays plain', () => { + setup(plannerWithBlur(false, { editingReservation: DINNER })) + expect(isBlurred(codeField())).toBe(false) + }) + + it('FE-MOB-RESSH-061: a blurred code still saves unchanged', async () => { + const { planner } = setup(plannerWithBlur(true, { editingReservation: DINNER })) + fireEvent.click(submitBtn()) + await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled()) + expect(savedPayload(planner).confirmation_number).toBe('C1') + }) + }) }) diff --git a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx index 3d43b062e..af6a9002b 100644 --- a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx @@ -3,12 +3,14 @@ import MTransportFormSheet from '../../../../src/mobile/screens/trip/sheets/MTra import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import { useAddonStore } from '../../../../src/store/addonStore' import { useTripStore } from '../../../../src/store/tripStore' +import { useSettingsStore } from '../../../../src/store/settingsStore' +import { isBlurred } from '../../../helpers/bookingCodeBlur' import type { Day, Reservation, TripMember } from '../../../../src/types' import { buildPlanner } from '../../../helpers/mobileTrip' import { resetAllStores, seedStore } from '../../../helpers/store' import { act, fireEvent, render, screen } from '../../../helpers/render' -// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-048 +// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-052 // // The sheet's own pickers (airport/location search, day select, time picker) and // the embedded transit panel are replaced by minimal controlled stand-ins so the @@ -957,4 +959,66 @@ describe('MTransportFormSheet', () => { fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' })) expect(daySelects().map(s => s.value)).toEqual(['13', '13']) }) + + // ── Blur booking codes in the edit sheet (#2457) ─────────────────────────── + + describe('blur booking codes (#2457)', () => { + /** Both sources the phone reads the preference from: the settings store and planner.settings. */ + function plannerWithBlur(on: boolean, overrides: Record = {}) { + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, blur_booking_codes: on } }) + return makePlanner({ + settings: { time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km', blur_booking_codes: on }, + ...overrides, + }) + } + const codeFields = () => screen.getAllByPlaceholderText('reservations.confirmationPlaceholder') as HTMLInputElement[] + + const layover = () => ({ + id: 45, trip_id: 1, type: 'flight', title: 'FRA-IST-HND', status: 'pending', + day_id: 11, end_day_id: 12, confirmation_number: 'BOOK1', + metadata: { + legs: [ + { from: 'FRA', to: 'IST', confirmation_number: 'ABC123', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05' }, + { from: 'IST', to: 'HND', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' }, + ], + }, + endpoints: [ + { id: 1, reservation_id: 45, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: null, local_time: null }, + { id: 2, reservation_id: 45, role: 'stop', sequence: 1, name: 'Istanbul (IST)', code: 'IST', lat: 41.27, lng: 28.75, timezone: 'Europe/Istanbul', local_date: null, local_time: null }, + { id: 3, reservation_id: 45, role: 'to', sequence: 2, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: null, local_time: null }, + ], + } as unknown as Reservation) + + it('FE-MOB-TRFRM-049: the booking code and every segment code are blurred while the setting is on', () => { + renderSheet(plannerWithBlur(true, { editingTransport: layover() })) + 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-MOB-TRFRM-050: focusing a code field reveals it for editing, leaving it hides it again', () => { + renderSheet(plannerWithBlur(true, { editingTransport: layover() })) + const field = codeFields()[2] + expect(isBlurred(field)).toBe(true) + act(() => field.focus()) + expect(isBlurred(field)).toBe(false) + act(() => field.blur()) + expect(isBlurred(field)).toBe(true) + }) + + it('FE-MOB-TRFRM-051: with the setting off every code field stays plain', () => { + renderSheet(plannerWithBlur(false, { editingTransport: layover() })) + expect(codeFields().map(i => isBlurred(i))).toEqual([false, false, false]) + }) + + it('FE-MOB-TRFRM-052: blurred codes still save unchanged', async () => { + const handleSaveTransport = makeSave() + renderSheet(plannerWithBlur(true, { editingTransport: layover(), handleSaveTransport })) + await submit('common.update') + const payload = handleSaveTransport.mock.calls[0][0] + const legs = (payload.metadata as { legs: Record[] }).legs + expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']) + expect(payload.confirmation_number).toBe('BOOK1') + }) + }) })