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:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user