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
@@ -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 <BookingCodeInput value={v} onChange={e => 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(<Field className="w-full border" />)
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(<Field />)
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(<Field className="w-full border" />)
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(<Field />)
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(<Field initial="" />)
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(<Field onFocus={onFocus} onBlur={onBlur} />)
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<HTMLInputElement>()
render(<BookingCodeInput ref={ref} value="X" onChange={() => {}} />)
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(<div data-testid="host"><BlurredCode>ABC999</BlurredCode></div>)
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(<BlurredCode>ABC999</BlurredCode>)
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(<div role="button" aria-label="Open booking" tabIndex={0} onClick={onRowClick}><BlurredCode>ABC999</BlurredCode></div>)
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(<div role="button" aria-label="Open booking" tabIndex={0} onKeyDown={e => onRowKey(e.key)}><BlurredCode>ABC999</BlurredCode></div>)
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(<button type="button"><BlurredCode interactive={false} className="font-geist">#X9</BlurredCode></button>)
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(<span data-testid="host">Confirmed · <BlurredCode interactive={false}>#X9</BlurredCode></span>)
expect(screen.getByTestId('host').innerHTML).toBe('Confirmed · #X9')
})
})
@@ -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<InputHTMLAttributes<HTMLInputElement>, 'type'> & {
ref?: Ref<HTMLInputElement>
}
/**
* 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 (
<input
type="text"
{...props}
className={hidden
? joinCls(className, 'blur-[4px] transition-[filter] duration-200 hover:blur-none focus:blur-none')
: className}
/>
)
}
/**
* 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 <span className={joinCls(className, BLUR_CLS)}>{children}</span>
return (
<button
type="button"
aria-pressed={revealed}
// The code often sits on a strip that opens its booking on a click or on
// Enter; revealing the code must not open the editor as well.
onClick={e => { e.stopPropagation(); setRevealed(v => !v) }}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') e.stopPropagation() }}
className={joinCls(className, 'cursor-pointer', !revealed && `${BLUR_CLS} hover:blur-none`)}
>
{children}
</button>
)
}