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">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
readStoredLocale,
|
||||
resolveLocale,
|
||||
translate,
|
||||
isTranslationKey,
|
||||
} from './LanguageContext'
|
||||
|
||||
const STORAGE_KEY = 'language_preference'
|
||||
@@ -119,6 +120,22 @@ describe('LanguageContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTranslationKey', () => {
|
||||
// Used to validate untyped strings from the backend (e.g.
|
||||
// `LeaveWish.nameMessageKey`, see src/utils/wishName.ts) before they are
|
||||
// ever passed to t()/translate() — an unknown or stale key must never
|
||||
// crash a render.
|
||||
it('accepts a real catalogue key', () => {
|
||||
expect(isTranslationKey('common.save')).toBe(true)
|
||||
expect(isTranslationKey('wishEditor.seasonJul')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a string that is not in the catalogue', () => {
|
||||
expect(isTranslationKey('not.a.real.key')).toBe(false)
|
||||
expect(isTranslationKey('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readStoredLocale', () => {
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(readStoredLocale()).toBeNull()
|
||||
|
||||
@@ -115,6 +115,22 @@ export function translate(locale: Locale, key: TranslationKey, params?: Translat
|
||||
return interpolate(CATALOGUES[locale][key], params)
|
||||
}
|
||||
|
||||
// Every locale is typed against `TranslationKey` (`en/` mirrors `sv/` field
|
||||
// for field), so `sv`'s own keys are the complete, authoritative keyset —
|
||||
// checking against it alone is enough to validate an arbitrary string.
|
||||
const KNOWN_TRANSLATION_KEYS = new Set<string>(Object.keys(sv))
|
||||
|
||||
/** Type guard for validating an untyped string (e.g. a value stored on a
|
||||
* backend record, like `LeaveWish.nameMessageKey`) before it is ever passed
|
||||
* to `t()`/`translate()`. A stale key from an old build, or one this build
|
||||
* simply doesn't recognise, must never crash a render or surface a raw key
|
||||
* to the user — callers should fall back to a literal display value instead
|
||||
* of asserting the type away.
|
||||
*/
|
||||
export function isTranslationKey(key: string): key is TranslationKey {
|
||||
return KNOWN_TRANSLATION_KEYS.has(key)
|
||||
}
|
||||
|
||||
export interface LanguageContextValue {
|
||||
/** The active, resolved locale — always concrete, never "auto". */
|
||||
locale: Locale
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
resolveLocale,
|
||||
translate,
|
||||
toTranslationParams,
|
||||
isTranslationKey,
|
||||
} from './LanguageContext'
|
||||
export type { Locale, TranslationParams, LanguageContextValue } from './LanguageContext'
|
||||
export type { TranslationKey } from './sv'
|
||||
|
||||
@@ -176,6 +176,7 @@ describe('API response mappers (snake_case → camelCase)', () => {
|
||||
workspace_id: 'ws1',
|
||||
child_id: 'child1',
|
||||
name: 'Sommar',
|
||||
name_message_key: 'wishEditor.presetSommar',
|
||||
window_start_month: 6,
|
||||
window_start_day: 15,
|
||||
window_end_month: 7,
|
||||
@@ -205,6 +206,7 @@ describe('API response mappers (snake_case → camelCase)', () => {
|
||||
expect(result[0]!.workspaceId).toBe('ws1')
|
||||
expect(result[0]!.childId).toBe('child1')
|
||||
expect(result[0]!.name).toBe('Sommar')
|
||||
expect(result[0]!.nameMessageKey).toBe('wishEditor.presetSommar')
|
||||
expect(result[0]!.windowStartMonth).toBe(6)
|
||||
expect(result[0]!.windowEndDay).toBe(26)
|
||||
expect(result[0]!.targetWeeks).toBe(6)
|
||||
@@ -301,6 +303,57 @@ describe('API response mappers (snake_case → camelCase)', () => {
|
||||
expect(result.windowStartMonth).toBe(6)
|
||||
expect(result.targetWeeks).toBe(6)
|
||||
})
|
||||
|
||||
it('sends name_message_key for a preset-created wish and maps it back', async () => {
|
||||
const rawResponse = {
|
||||
id: 'lw1',
|
||||
workspace_id: 'ws1',
|
||||
child_id: null,
|
||||
name: 'Jul',
|
||||
name_message_key: 'wishEditor.seasonJul',
|
||||
window_start_month: 12,
|
||||
window_start_day: 1,
|
||||
window_end_month: 1,
|
||||
window_end_day: 31,
|
||||
target_weeks: 4,
|
||||
recurrence: 'yearly',
|
||||
first_year: 2026,
|
||||
last_year: null,
|
||||
priority: 1,
|
||||
parent_mode: 'either',
|
||||
parent_personal_data_uuid: null,
|
||||
goal_override: null,
|
||||
created_at: '2026-06-14T17:09:47',
|
||||
updated_at: '2026-06-14T17:09:47',
|
||||
}
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => rawResponse,
|
||||
})
|
||||
|
||||
const result = await api.createLeaveWish({
|
||||
workspaceId: 'ws1',
|
||||
name: 'Jul',
|
||||
nameMessageKey: 'wishEditor.seasonJul',
|
||||
windowStartMonth: 12,
|
||||
windowStartDay: 1,
|
||||
windowEndMonth: 1,
|
||||
windowEndDay: 31,
|
||||
targetWeeks: 4,
|
||||
recurrence: 'yearly',
|
||||
firstYear: 2026,
|
||||
})
|
||||
|
||||
const calls = (fetchMock.mock?.calls ?? []) as any[][]
|
||||
if (calls.length === 0) throw new Error('No fetch calls made')
|
||||
const [, options] = calls[0]!
|
||||
const body = JSON.parse(options.body)
|
||||
|
||||
expect(body.name_message_key).toBe('wishEditor.seasonJul')
|
||||
expect(result.nameMessageKey).toBe('wishEditor.seasonJul')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkspace', () => {
|
||||
@@ -829,6 +882,46 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
expect(result.targetWeeks).toBe(8)
|
||||
})
|
||||
|
||||
it('updateLeaveWish sends name_message_key: null to explicitly clear it on rename', async () => {
|
||||
// Mirrors what WishEditor sends the moment a user edits a preset wish's
|
||||
// name — the request must carry an explicit `null`, not omit the field
|
||||
// (omitting it would mean "leave unchanged" server-side).
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
id: 'lw1',
|
||||
workspace_id: 'ws1',
|
||||
child_id: null,
|
||||
name: 'Vinterresa',
|
||||
name_message_key: null,
|
||||
window_start_month: 6,
|
||||
window_start_day: 15,
|
||||
window_end_month: 7,
|
||||
window_end_day: 26,
|
||||
target_weeks: 6,
|
||||
recurrence: 'yearly',
|
||||
first_year: 2026,
|
||||
last_year: null,
|
||||
priority: 1,
|
||||
parent_mode: 'either',
|
||||
parent_personal_data_uuid: null,
|
||||
goal_override: null,
|
||||
created_at: '2026-01-01',
|
||||
updated_at: '2026-01-01',
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await api.updateLeaveWish('lw1', {
|
||||
name: 'Vinterresa',
|
||||
nameMessageKey: null,
|
||||
})
|
||||
|
||||
expect(lastRequestBody()).toEqual({ name: 'Vinterresa', name_message_key: null })
|
||||
expect(result.name).toBe('Vinterresa')
|
||||
expect(result.nameMessageKey).toBeNull()
|
||||
})
|
||||
|
||||
it('updateLeaveWish sends every provided field, snake_cased', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -206,6 +206,7 @@ interface RawLeaveWish {
|
||||
workspace_id: string
|
||||
child_id: string | null
|
||||
name: string
|
||||
name_message_key: string | null
|
||||
window_start_month: number
|
||||
window_start_day: number
|
||||
window_end_month: number
|
||||
@@ -229,6 +230,7 @@ function toLeaveWish(raw: RawLeaveWish): LeaveWish {
|
||||
workspaceId: raw.workspace_id,
|
||||
childId: raw.child_id,
|
||||
name: raw.name,
|
||||
nameMessageKey: raw.name_message_key,
|
||||
windowStartMonth: raw.window_start_month,
|
||||
windowStartDay: raw.window_start_day,
|
||||
windowEndMonth: raw.window_end_month,
|
||||
@@ -757,6 +759,7 @@ export async function createLeaveWish(payload: LeaveWishCreateRequest): Promise<
|
||||
workspace_id: payload.workspaceId,
|
||||
child_id: payload.childId ?? null,
|
||||
name: payload.name,
|
||||
name_message_key: payload.nameMessageKey ?? null,
|
||||
window_start_month: payload.windowStartMonth,
|
||||
window_start_day: payload.windowStartDay,
|
||||
window_end_month: payload.windowEndMonth,
|
||||
@@ -782,6 +785,7 @@ export async function updateLeaveWish(
|
||||
const body: Record<string, unknown> = {}
|
||||
if (payload.childId !== undefined) body.child_id = payload.childId
|
||||
if (payload.name !== undefined) body.name = payload.name
|
||||
if (payload.nameMessageKey !== undefined) body.name_message_key = payload.nameMessageKey
|
||||
if (payload.windowStartMonth !== undefined) body.window_start_month = payload.windowStartMonth
|
||||
if (payload.windowStartDay !== undefined) body.window_start_day = payload.windowStartDay
|
||||
if (payload.windowEndMonth !== undefined) body.window_end_month = payload.windowEndMonth
|
||||
|
||||
@@ -71,6 +71,10 @@ export interface LeaveWishCreateRequest {
|
||||
workspaceId: string
|
||||
childId?: string | null
|
||||
name: string
|
||||
// The i18n key `name` was translated from, when created from a preset —
|
||||
// see `LeaveWish.nameMessageKey`. `undefined`/omitted means "no key"
|
||||
// (a free-typed name); passing `null` on an update explicitly clears it.
|
||||
nameMessageKey?: string | null
|
||||
windowStartMonth: number
|
||||
windowStartDay: number
|
||||
windowEndMonth: number
|
||||
|
||||
@@ -33,6 +33,12 @@ export interface LeaveWish {
|
||||
workspaceId: string
|
||||
childId: string | null
|
||||
name: string
|
||||
// i18n provenance of `name` (see backend/app/entities/leave_wish.py):
|
||||
// the message key a preset (Sommar/Jullov/...) was created from, or
|
||||
// `null` for a free-typed/edited name. An untyped string as it arrives —
|
||||
// resolve display text through `resolveWishName` (src/utils/wishName.ts),
|
||||
// never `t()` directly, since a stale/unknown key must not crash.
|
||||
nameMessageKey: string | null
|
||||
windowStartMonth: number
|
||||
windowStartDay: number
|
||||
windowEndMonth: number
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveWishName } from './wishName'
|
||||
import { translate } from '@/i18n'
|
||||
|
||||
const t = (key: Parameters<typeof translate>[1]) => translate('sv', key)
|
||||
|
||||
describe('resolveWishName', () => {
|
||||
it('translates through nameMessageKey when present, ignoring the stored name', () => {
|
||||
// The core bug fix: the persisted `name` can be stale (saved while the
|
||||
// UI was a different language) — the key, when present, is the source
|
||||
// of truth for display.
|
||||
const wish = { name: 'STALE_ENGLISH_NAME', nameMessageKey: 'wishEditor.seasonJul' }
|
||||
expect(resolveWishName(wish, t)).toBe('Jul')
|
||||
})
|
||||
|
||||
it('returns the literal name verbatim when nameMessageKey is null', () => {
|
||||
const wish = { name: 'Resa till mormor', nameMessageKey: null }
|
||||
expect(resolveWishName(wish, t)).toBe('Resa till mormor')
|
||||
})
|
||||
|
||||
it('falls back to the literal name for a key this build does not recognise', () => {
|
||||
// A stale key from an older/newer server build must never crash or
|
||||
// render a raw, untranslated key string to the user.
|
||||
const wish = { name: 'Sommar', nameMessageKey: 'wishEditor.thisKeyDoesNotExist' }
|
||||
expect(resolveWishName(wish, t)).toBe('Sommar')
|
||||
})
|
||||
|
||||
it('falls back to the literal name for an empty-string key', () => {
|
||||
const wish = { name: 'Custom trip', nameMessageKey: '' }
|
||||
expect(resolveWishName(wish, t)).toBe('Custom trip')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { isTranslationKey, type TranslationKey } from '@/i18n'
|
||||
|
||||
/** The subset of `LeaveWish` `resolveWishName` needs — kept minimal so any
|
||||
* call site (the full entity, a plan-outcome projection, a test fixture,
|
||||
* ...) can pass itself in without an adapter.
|
||||
*/
|
||||
export interface NamedWish {
|
||||
name: string
|
||||
nameMessageKey: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a leave wish's display name, following its i18n provenance when
|
||||
* present.
|
||||
*
|
||||
* Fixes the bug where preset wishes (Sommar/Jullov/Höstlov/...) were created
|
||||
* with `name` set to the already-translated string, so the persisted text
|
||||
* stayed in whatever language the UI was in at creation time forever —
|
||||
* switching locale later could never change it. `nameMessageKey` (see
|
||||
* `backend/app/entities/leave_wish.py`) records which preset produced the
|
||||
* name, if any, so it can be re-translated on every render.
|
||||
*
|
||||
* `nameMessageKey` arrives off the wire as a plain string, not a
|
||||
* `TranslationKey` — it may be `null` (a free-typed or user-edited name, or
|
||||
* any row created before this field existed), or a key this build doesn't
|
||||
* recognise (an older/newer server, a stale row). Both fall back to the
|
||||
* literal `name` rather than crashing or ever rendering a raw key.
|
||||
*/
|
||||
export function resolveWishName(wish: NamedWish, t: (key: TranslationKey) => string): string {
|
||||
if (wish.nameMessageKey !== null && isTranslationKey(wish.nameMessageKey)) {
|
||||
return t(wish.nameMessageKey)
|
||||
}
|
||||
return wish.name
|
||||
}
|
||||
Reference in New Issue
Block a user