Show preset wish names in the current UI language

Preset wishes were created with `name: t(preset.labelKey)`, so the
translated string was persisted as the wish's name. A wish created while
the UI was Swedish stayed "Jul" in English forever -- the Swedish text
was the data, so no display-layer change could fix it.

Wish names are user-editable free text, so `name` cannot simply be
translated at render time. Migration 007 adds a nullable
`leave_wish.name_message_key` recording where a name came from: preset
wishes store the i18n key alongside the translated literal, and the UI
renders the key when present.

The key is cleared server-side the moment `name` actually changes,
compared against the stored value rather than trusting the caller, so no
client can forget to clear it and silently revert a user's own name to a
preset label.

An unknown key (a stale row, a renamed key) falls back to the stored
literal via an isTranslationKey guard, so a bad key can never crash the
page or leak a raw key to the user. Rows predating this column keep
displaying their stored name.

_FULL_SCHEMA in db/connection.py is updated in step with the migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
2026-09-14 22:32:54 +02:00
parent b03462066e
commit dc4c0a5edd
20 changed files with 668 additions and 17 deletions
@@ -8,6 +8,7 @@ const mockWish: LeaveWish = {
workspaceId: 'ws1',
childId: null,
name: 'Sommar',
nameMessageKey: null,
windowStartMonth: 6,
windowStartDay: 15,
windowEndMonth: 7,
@@ -103,6 +104,11 @@ describe('WishEditor', () => {
expect.objectContaining({
workspaceId: 'ws1',
name: 'Sommar',
// The fix for the "Jul"/"Höstlov" always-Swedish bug: a preset wish
// carries the i18n key that produced its name, so switching locale
// later can re-translate it instead of the persisted text being
// stuck in whatever language the UI happened to be in.
nameMessageKey: 'wishEditor.presetSommar',
windowStartMonth: 6,
windowStartDay: 15,
windowEndMonth: 7,
@@ -122,6 +128,7 @@ describe('WishEditor', () => {
expect(onAdd).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Jullov',
nameMessageKey: 'wishEditor.presetJullov',
windowStartMonth: 12,
windowStartDay: 20,
windowEndMonth: 1,
@@ -136,7 +143,12 @@ describe('WishEditor', () => {
fireEvent.click(screen.getByTestId('wish-preset-custom'))
expect(onAdd).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'ws1' }))
expect(onAdd).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: 'ws1',
nameMessageKey: 'wishEditor.customLeaveName',
}),
)
})
it('renders an existing wish as a card', () => {
@@ -280,6 +292,7 @@ describe('WishEditor', () => {
expect(onAdd).toHaveBeenCalledWith(
expect.objectContaining({
windowMode: 'flexible',
nameMessageKey: 'wishEditor.seasonVar',
windowStartMonth: 3,
windowStartDay: 1,
windowEndMonth: 5,
@@ -342,6 +355,7 @@ describe('WishEditor', () => {
expect.objectContaining({
workspaceId: 'ws1',
windowMode: 'flexible',
nameMessageKey: 'wishEditor.customFlexName',
}),
)
})
@@ -572,4 +586,77 @@ describe('WishEditor', () => {
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('nameMessageKey (preset name follows the UI language)', () => {
it('shows the translated name when nameMessageKey is set, even if the stored name is stale', () => {
// Simulates the bug scenario: `name` was persisted in whatever
// language the wish was created in ("STALE_ENGLISH_NAME" here stands
// in for e.g. "Christmas" saved while the UI was English), but the
// test environment is pinned to Swedish (tests/setup.ts) — the input
// must show the current-locale translation of the key, not the
// stored, possibly-stale `name`.
const presetWish: LeaveWish = {
...mockWish,
name: 'STALE_ENGLISH_NAME',
nameMessageKey: 'wishEditor.seasonJul',
}
setup({ wishes: [presetWish] })
expect(screen.getByTestId('wish-name-lw1')).toHaveValue('Jul')
})
it('shows the literal name verbatim when nameMessageKey is null (free-typed or pre-existing wish)', () => {
const customWish: LeaveWish = { ...mockWish, name: 'Resa till mormor', nameMessageKey: null }
setup({ wishes: [customWish] })
expect(screen.getByTestId('wish-name-lw1')).toHaveValue('Resa till mormor')
})
it('falls back to the literal name for an nameMessageKey this build does not recognise', () => {
// A stale/future key (older or newer server, a renamed catalogue
// entry, ...) must never crash the row or render a raw key.
const staleKeyWish: LeaveWish = {
...mockWish,
name: 'Sommar',
nameMessageKey: 'wishEditor.noLongerExists',
}
setup({ wishes: [staleKeyWish] })
expect(screen.getByTestId('wish-name-lw1')).toHaveValue('Sommar')
})
it('clears nameMessageKey the moment the user edits the name, and auto-saves the literal text', () => {
const presetWish: LeaveWish = {
...mockWish,
name: 'Jul',
nameMessageKey: 'wishEditor.seasonJul',
}
const { onUpdate } = setup({ wishes: [presetWish] })
fireEvent.change(screen.getByTestId('wish-name-lw1'), { target: { value: 'Vinterresa' } })
advanceAutosave()
expect(onUpdate).toHaveBeenCalledWith(
'lw1',
expect.objectContaining({ name: 'Vinterresa', nameMessageKey: null }),
)
})
it('does not re-save a preset wish on mount just because its resolved name differs from the stored name', () => {
// Guards against a regression where seeding the draft with the
// *resolved* display name (instead of the raw `name`) would make the
// dirty-check think an untouched row had been edited, firing a bogus
// rename save on every load.
const presetWish: LeaveWish = {
...mockWish,
name: 'Jul',
nameMessageKey: 'wishEditor.seasonJul',
}
const { onUpdate } = setup({ wishes: [presetWish] })
advanceAutosave(5000)
expect(onUpdate).not.toHaveBeenCalled()
})
})
})
+42 -12
View File
@@ -35,6 +35,7 @@ import type {
PersonalData,
} from '@/types'
import { useTranslation, type TranslationKey } from '@/i18n'
import { resolveWishName } from '@/utils/wishName'
export interface WishEditorProps {
workspaceId: string
@@ -133,6 +134,13 @@ const MONTH_KEYS: TranslationKey[] = [
interface Draft {
name: string
// Mirrors `LeaveWish.nameMessageKey`: the i18n key `name` was translated
// from when created via preset, or `null` for a free-typed/edited name.
// Cleared the moment the name input is edited (see the `onChange` below) —
// the backend also enforces this independently (see
// `LeaveWishService.update`), but clearing it here too keeps the draft's
// dirty-check (`draftEqualsWish`) and the save it triggers consistent.
nameMessageKey: string | null
targetWeeks: number
priority: number
parentMode: 'either' | 'both' | 'specific'
@@ -147,9 +155,15 @@ interface Draft {
windowMode: WindowMode
}
function draftFromWish(wish: LeaveWish): Draft {
function draftFromWish(wish: LeaveWish, t: (key: TranslationKey) => string): Draft {
return {
name: wish.name,
// Resolved through the wish's i18n provenance (§ nameMessageKey above),
// not `wish.name` directly — this is the actual fix for the bug where a
// preset wish's stored, already-translated name never changed with the
// UI language. A free-typed/edited name (no key) is unaffected: it's
// literal text either way.
name: resolveWishName(wish, t),
nameMessageKey: wish.nameMessageKey,
targetWeeks: wish.targetWeeks,
priority: wish.priority,
parentMode: wish.parentMode,
@@ -186,9 +200,14 @@ function isBoundTooShort(draft: Draft): boolean {
* would send — the dirty check that stops a reseeded-but-untouched draft
* (mount, or a parent refetch after another row's save) from ever
* scheduling a save. */
function draftEqualsWish(draft: Draft, wish: LeaveWish): boolean {
function draftEqualsWish(
draft: Draft,
wish: LeaveWish,
t: (key: TranslationKey) => string,
): boolean {
return (
draft.name === wish.name &&
draft.name === resolveWishName(wish, t) &&
draft.nameMessageKey === wish.nameMessageKey &&
draft.targetWeeks === wish.targetWeeks &&
draft.priority === wish.priority &&
draft.parentMode === wish.parentMode &&
@@ -209,6 +228,7 @@ function draftEqualsWish(draft: Draft, wish: LeaveWish): boolean {
function buildPatch(draft: Draft): LeaveWishUpdateRequest {
return {
name: draft.name,
nameMessageKey: draft.nameMessageKey,
targetWeeks: draft.targetWeeks,
priority: draft.priority,
parentMode: draft.parentMode,
@@ -263,11 +283,11 @@ export function WishEditor({
setDrafts((prev) => {
const next: Record<string, Draft> = {}
for (const wish of wishes) {
next[wish.id] = prev[wish.id] ?? draftFromWish(wish)
next[wish.id] = prev[wish.id] ?? draftFromWish(wish, t)
}
return next
})
}, [wishes])
}, [wishes, t])
// Resolves in-flight auto-saves: a row whose sent draft now matches the
// freshly-arrived `wishes` succeeded; a row still unresolved when the
@@ -284,7 +304,8 @@ export function WishEditor({
for (const id of Object.keys(sentDraftRef.current)) {
const sentDraft = sentDraftRef.current[id]
const wish = wishes.find((w) => w.id === id)
const succeeded = wish !== undefined && sentDraft !== undefined && draftEqualsWish(sentDraft, wish)
const succeeded =
wish !== undefined && sentDraft !== undefined && draftEqualsWish(sentDraft, wish, t)
if (succeeded) {
delete sentDraftRef.current[id]
@@ -305,7 +326,7 @@ export function WishEditor({
setRowStatus((prev) => ({ ...prev, [id]: 'error' }))
}
}
}, [wishes, isSaving])
}, [wishes, isSaving, t])
// Clean up every outstanding timer on unmount so a debounced save (or a
// "Sparat" auto-clear) never fires against an unmounted component. Both
@@ -327,6 +348,7 @@ export function WishEditor({
onAdd({
workspaceId,
name: t(preset.labelKey),
nameMessageKey: preset.labelKey,
windowStartMonth: preset.startMonth,
windowStartDay: preset.startDay,
windowEndMonth: preset.endMonth,
@@ -347,6 +369,7 @@ export function WishEditor({
onAdd({
workspaceId,
name: t(preset.labelKey),
nameMessageKey: preset.labelKey,
windowStartMonth: preset.startMonth,
windowStartDay: preset.startDay,
windowEndMonth: preset.endMonth,
@@ -364,6 +387,7 @@ export function WishEditor({
onAdd({
workspaceId,
name: t('wishEditor.customLeaveName'),
nameMessageKey: 'wishEditor.customLeaveName',
windowStartMonth: 6,
windowStartDay: 1,
windowEndMonth: 6,
@@ -384,6 +408,7 @@ export function WishEditor({
onAdd({
workspaceId,
name: t('wishEditor.customFlexName'),
nameMessageKey: 'wishEditor.customFlexName',
windowStartMonth: 6,
windowStartDay: 1,
windowEndMonth: 6,
@@ -404,7 +429,7 @@ export function WishEditor({
const wish = wishes.find((w) => w.id === id)
const draft = draftsRef.current[id]
if (!wish || !draft) return
if (isBoundTooShort(draft) || draftEqualsWish(draft, wish)) return
if (isBoundTooShort(draft) || draftEqualsWish(draft, wish, t)) return
sentDraftRef.current[id] = draft
setRowStatus((prev) => ({ ...prev, [id]: 'saving' }))
@@ -508,7 +533,7 @@ export function WishEditor({
) : (
<ul className="wish-editor__list">
{wishes.map((wish) => {
const draft = drafts[wish.id] ?? draftFromWish(wish)
const draft = drafts[wish.id] ?? draftFromWish(wish, t)
const isExpanded = expanded[wish.id] ?? false
const parentSelectValue =
draft.parentMode === 'specific' ? draft.parentPersonalDataUuid ?? '' : draft.parentMode
@@ -527,10 +552,15 @@ export function WishEditor({
<input
type="text"
value={draft.name}
onChange={(e) => updateDraft(wish.id, { name: e.target.value })}
onChange={(e) =>
// Editing the name detaches it from any preset: it is
// now the user's own text and must never be silently
// re-translated back to a stale preset label.
updateDraft(wish.id, { name: e.target.value, nameMessageKey: null })
}
className="wish-editor__name-input"
data-testid={`wish-name-${wish.id}`}
aria-label={t('wishEditor.nameAria', { name: wish.name })}
aria-label={t('wishEditor.nameAria', { name: resolveWishName(wish, t) })}
/>
<label className="wish-editor__field">