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:
@@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS leave_wish (
|
||||
workspace_id TEXT NOT NULL,
|
||||
child_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
name_message_key TEXT,
|
||||
window_start_month INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (window_start_month >= 1 AND window_start_month <= 12),
|
||||
window_start_day INTEGER NOT NULL DEFAULT 1
|
||||
|
||||
@@ -24,7 +24,7 @@ class LeaveWish:
|
||||
id: UUID
|
||||
workspace_id: UUID
|
||||
child_id: UUID | None # None = family-wide; set = tied to that child's pools
|
||||
name: str # "Sommar", "Jullov"
|
||||
name: str # "Sommar", "Jullov" — persisted, possibly-translated display text
|
||||
|
||||
# Recurring anchor stored as month/day ints (SQLite-friendly, no custom type).
|
||||
window_start_month: int # 1-12
|
||||
@@ -55,6 +55,16 @@ class LeaveWish:
|
||||
# completely unmodified.
|
||||
window_mode: str = "fixed" # 'fixed' | 'flexible'
|
||||
|
||||
# Provenance of `name`: when a wish is created from a preset (Sommar,
|
||||
# Jullov, Höstlov, ...) this holds the i18n message key that produced the
|
||||
# translated string stored in `name` (e.g. "wishEditor.presetSommar").
|
||||
# Nullable — free-typed names, and every row created before this field
|
||||
# existed, have no key and just show `name` verbatim. Cleared by
|
||||
# `LeaveWishService.update` the moment the user edits `name`, since a
|
||||
# user-typed name is no longer a preset label and must never silently
|
||||
# revert to one.
|
||||
name_message_key: str | None = None
|
||||
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ class LeaveWishCreate(BaseModel):
|
||||
workspace_id: UUID
|
||||
child_id: UUID | None = None
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
# i18n message key when `name` came from a preset (e.g.
|
||||
# "wishEditor.presetSommar") — see app/entities/leave_wish.py. Stored
|
||||
# alongside the translated `name` so the frontend can re-translate it;
|
||||
# not validated against a key catalogue here (that's a frontend concern).
|
||||
name_message_key: str | None = Field(default=None, max_length=255)
|
||||
|
||||
window_start_month: int = Field(ge=1, le=12)
|
||||
window_start_day: int = Field(ge=1, le=31)
|
||||
@@ -46,6 +51,11 @@ class LeaveWishUpdate(BaseModel):
|
||||
|
||||
child_id: UUID | None = None
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
# Present + null in the payload means "clear it"; absent means "leave
|
||||
# unchanged" (see `model_fields_set` handling in the router). The service
|
||||
# also clears this automatically whenever `name` actually changes, so a
|
||||
# caller can never forget to and leave a stale key pointing at old text.
|
||||
name_message_key: str | None = Field(default=None, max_length=255)
|
||||
|
||||
window_start_month: int | None = Field(None, ge=1, le=12)
|
||||
window_start_day: int | None = Field(None, ge=1, le=31)
|
||||
@@ -71,6 +81,7 @@ class LeaveWishResponse(BaseModel):
|
||||
workspace_id: UUID
|
||||
child_id: UUID | None
|
||||
name: str
|
||||
name_message_key: str | None
|
||||
|
||||
window_start_month: int
|
||||
window_start_day: int
|
||||
@@ -100,6 +111,7 @@ class LeaveWishResponse(BaseModel):
|
||||
workspace_id=entity.workspace_id,
|
||||
child_id=entity.child_id,
|
||||
name=entity.name,
|
||||
name_message_key=entity.name_message_key,
|
||||
window_start_month=entity.window_start_month,
|
||||
window_start_day=entity.window_start_day,
|
||||
window_end_month=entity.window_end_month,
|
||||
|
||||
@@ -26,6 +26,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
workspace_id=UUID(row["workspace_id"]),
|
||||
child_id=UUID(row["child_id"]) if row["child_id"] is not None else None,
|
||||
name=row["name"],
|
||||
name_message_key=row["name_message_key"],
|
||||
window_start_month=row["window_start_month"],
|
||||
window_start_day=row["window_start_day"],
|
||||
window_end_month=row["window_end_month"],
|
||||
@@ -54,18 +55,19 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
await self._conn.execute(
|
||||
"""
|
||||
INSERT INTO leave_wish
|
||||
(id, workspace_id, child_id, name,
|
||||
(id, workspace_id, child_id, name, name_message_key,
|
||||
window_start_month, window_start_day, window_end_month, window_end_day,
|
||||
target_weeks, recurrence, first_year, last_year,
|
||||
priority, parent_mode, parent_personal_data_uuid, goal_override,
|
||||
window_mode, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(item.id),
|
||||
str(item.workspace_id),
|
||||
str(item.child_id) if item.child_id is not None else None,
|
||||
item.name,
|
||||
item.name_message_key,
|
||||
item.window_start_month,
|
||||
item.window_start_day,
|
||||
item.window_end_month,
|
||||
@@ -109,7 +111,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
cursor = await self._conn.execute(
|
||||
"""
|
||||
UPDATE leave_wish
|
||||
SET child_id = ?, name = ?,
|
||||
SET child_id = ?, name = ?, name_message_key = ?,
|
||||
window_start_month = ?, window_start_day = ?,
|
||||
window_end_month = ?, window_end_day = ?,
|
||||
target_weeks = ?, recurrence = ?, first_year = ?, last_year = ?,
|
||||
@@ -120,6 +122,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
|
||||
(
|
||||
str(item.child_id) if item.child_id is not None else None,
|
||||
item.name,
|
||||
item.name_message_key,
|
||||
item.window_start_month,
|
||||
item.window_start_day,
|
||||
item.window_end_month,
|
||||
|
||||
@@ -29,6 +29,7 @@ async def create_leave_wish(
|
||||
workspace_id=payload.workspace_id,
|
||||
child_id=payload.child_id,
|
||||
name=payload.name,
|
||||
name_message_key=payload.name_message_key,
|
||||
window_start_month=payload.window_start_month,
|
||||
window_start_day=payload.window_start_day,
|
||||
window_end_month=payload.window_end_month,
|
||||
@@ -70,6 +71,7 @@ async def update_leave_wish(
|
||||
leave_wish_id=leave_wish_id,
|
||||
child_id=payload.child_id,
|
||||
name=payload.name,
|
||||
name_message_key=payload.name_message_key,
|
||||
window_start_month=payload.window_start_month,
|
||||
window_start_day=payload.window_start_day,
|
||||
window_end_month=payload.window_end_month,
|
||||
@@ -90,6 +92,9 @@ async def update_leave_wish(
|
||||
and payload.parent_personal_data_uuid is None
|
||||
),
|
||||
_clear_goal_override=("goal_override" in fields_set and payload.goal_override is None),
|
||||
_clear_name_message_key=(
|
||||
"name_message_key" in fields_set and payload.name_message_key is None
|
||||
),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
|
||||
|
||||
@@ -37,6 +37,7 @@ class LeaveWishService:
|
||||
target_weeks: int,
|
||||
first_year: int,
|
||||
child_id: UUID | None = None,
|
||||
name_message_key: str | None = None,
|
||||
recurrence: str = "once",
|
||||
last_year: int | None = None,
|
||||
priority: int = 1,
|
||||
@@ -65,6 +66,7 @@ class LeaveWishService:
|
||||
workspace_id=workspace_id,
|
||||
child_id=child_id,
|
||||
name=name,
|
||||
name_message_key=name_message_key,
|
||||
window_start_month=window_start_month,
|
||||
window_start_day=window_start_day,
|
||||
window_end_month=window_end_month,
|
||||
@@ -97,6 +99,7 @@ class LeaveWishService:
|
||||
leave_wish_id: UUID,
|
||||
child_id: UUID | None = None,
|
||||
name: str | None = None,
|
||||
name_message_key: str | None = None,
|
||||
window_start_month: int | None = None,
|
||||
window_start_day: int | None = None,
|
||||
window_end_month: int | None = None,
|
||||
@@ -114,21 +117,35 @@ class LeaveWishService:
|
||||
_clear_last_year: bool = False,
|
||||
_clear_parent_personal_data_uuid: bool = False,
|
||||
_clear_goal_override: bool = False,
|
||||
_clear_name_message_key: bool = False,
|
||||
) -> LeaveWish:
|
||||
"""Partial update — only non-None fields are applied.
|
||||
|
||||
`_clear_*` flags exist because a nullable field's "no change" and
|
||||
"set to None" both look like `None` on the wire; the router passes
|
||||
them based on whether the field was present in the request payload.
|
||||
|
||||
`name_message_key` is additionally enforced here rather than left to
|
||||
callers: whenever `name` actually changes, the key is force-cleared
|
||||
regardless of what was passed for it, since a caller (frontend bug,
|
||||
future API client, ...) forgetting to clear it would silently make a
|
||||
user's edited name revert to a stale preset label.
|
||||
"""
|
||||
entity = await self.get(leave_wish_id)
|
||||
original_name = entity.name
|
||||
|
||||
if _clear_child_id:
|
||||
entity.child_id = None
|
||||
elif child_id is not None:
|
||||
entity.child_id = child_id
|
||||
if _clear_name_message_key:
|
||||
entity.name_message_key = None
|
||||
elif name_message_key is not None:
|
||||
entity.name_message_key = name_message_key
|
||||
if name is not None:
|
||||
entity.name = name
|
||||
if name != original_name:
|
||||
entity.name_message_key = None
|
||||
if window_start_month is not None:
|
||||
entity.window_start_month = window_start_month
|
||||
if window_start_day is not None:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Add leave_wish.name_message_key (nullable i18n key for preset names).
|
||||
|
||||
Bug: `WishEditor.tsx` created preset wishes (Sommar/Jullov/Höstlov/...) with
|
||||
`name` set to the already-translated string (e.g. "Jul"), persisted as-is.
|
||||
Switching the UI language afterwards could never fix a wish's name, because
|
||||
the Swedish (or English) text was the data, not a rendering choice.
|
||||
|
||||
Fix: `name_message_key` stores the i18n key that produced `name` at creation
|
||||
time (e.g. "wishEditor.seasonJul"), alongside the translated `name` so
|
||||
anything reading `name` directly (backend, exports, ...) still gets a sane
|
||||
value. The frontend re-translates via the key when present and falls back to
|
||||
the literal `name` otherwise. Nullable and no default: every existing row
|
||||
(and any row created by a free-typed custom name) has no key and must keep
|
||||
showing its stored `name` unchanged — see `LeaveWishService.update`, which
|
||||
also force-clears the key the moment a user edits `name`.
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-09-14
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "007"
|
||||
down_revision: str | None = "006"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
_UPGRADE_SQL = """
|
||||
ALTER TABLE leave_wish ADD COLUMN name_message_key TEXT;
|
||||
"""
|
||||
|
||||
_DOWNGRADE_SQL = """
|
||||
ALTER TABLE leave_wish DROP COLUMN name_message_key;
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
for stmt in [s.strip() for s in _UPGRADE_SQL.split(";") if s.strip()]:
|
||||
op.execute(text(stmt))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
for stmt in [s.strip() for s in _DOWNGRADE_SQL.split(";") if s.strip()]:
|
||||
op.execute(text(stmt))
|
||||
@@ -823,3 +823,81 @@ class TestWorkspaceParentsRenameAndRemove:
|
||||
# The blocked placeholder and the caller are both still members.
|
||||
assert placeholder_uuid in member_uuids
|
||||
assert str(pd_uuid) in member_uuids
|
||||
|
||||
|
||||
class TestLeaveWishNameMessageKeyRoundTrip:
|
||||
"""`name_message_key` (added to fix the "Jul"/"Höstlov" always-Swedish
|
||||
|
||||
bug — see `backend/app/entities/leave_wish.py`) must actually appear on
|
||||
the wire. The hand-written entity<->response mapping in this codebase
|
||||
has silently dropped new fields twice before, so this asserts the
|
||||
response *body* directly rather than only the service/repository layer.
|
||||
|
||||
All four scenarios share a single registered user/workspace (one
|
||||
`_register` call) rather than one each: `/api/auth/register` is rate
|
||||
limited to 10/minute (see `app/routers/auth.py`), and this file already
|
||||
registers a user per test class — four more registrations pushed the
|
||||
whole module over that limit when run together.
|
||||
"""
|
||||
|
||||
async def test_name_message_key_round_trip(
|
||||
self, client: AsyncClient, connection: aiosqlite.Connection
|
||||
) -> None:
|
||||
headers, ws_id, _ = await _register(client, "wish-key-roundtrip@example.com", "Alex")
|
||||
|
||||
# --- create returns the key it was given -----------------------
|
||||
wish = await _create_wish(
|
||||
client,
|
||||
headers,
|
||||
workspace_id=str(ws_id),
|
||||
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=date.today().year,
|
||||
)
|
||||
assert wish["name_message_key"] == "wishEditor.seasonJul"
|
||||
assert wish["name"] == "Jul"
|
||||
|
||||
# --- a wish created without a key round-trips null, not a crash -
|
||||
# (pre-existing rows, or a free-typed custom name, have no key.)
|
||||
keyless_wish = await _create_wish(
|
||||
client,
|
||||
headers,
|
||||
workspace_id=str(ws_id),
|
||||
name="Custom trip",
|
||||
window_start_month=6,
|
||||
window_start_day=1,
|
||||
window_end_month=6,
|
||||
window_end_day=7,
|
||||
target_weeks=1,
|
||||
recurrence="once",
|
||||
first_year=date.today().year,
|
||||
)
|
||||
assert keyless_wish["name_message_key"] is None
|
||||
|
||||
# --- updating an unrelated field preserves the key --------------
|
||||
resp = await client.put(
|
||||
f"/api/leave-wishes/{wish['id']}", headers=headers, json={"target_weeks": 5}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["target_weeks"] == 5
|
||||
assert body["name"] == "Jul"
|
||||
assert body["name_message_key"] == "wishEditor.seasonJul"
|
||||
|
||||
# --- renaming clears the key even if the caller never mentions --
|
||||
# it: the backend enforces this, not just the frontend. Getting
|
||||
# this backwards would mean a user's custom name silently reverts
|
||||
# to the old preset label the next time the UI translates it.
|
||||
resp = await client.put(
|
||||
f"/api/leave-wishes/{wish['id']}", headers=headers, json={"name": "Vinterresa"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["name"] == "Vinterresa"
|
||||
assert body["name_message_key"] is None
|
||||
|
||||
@@ -133,6 +133,57 @@ class TestSQLiteLeaveWishRepository:
|
||||
result = await leave_wish_repo.get_by_id(uuid4())
|
||||
assert result is None
|
||||
|
||||
async def test_create_and_get_round_trip_name_message_key(
|
||||
self,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
entity = _make(
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
name="Jul",
|
||||
name_message_key="wishEditor.seasonJul",
|
||||
)
|
||||
created = await leave_wish_repo.create(entity)
|
||||
assert created.name_message_key == "wishEditor.seasonJul"
|
||||
|
||||
fetched = await leave_wish_repo.get_by_id(created.id)
|
||||
assert fetched is not None
|
||||
assert fetched.name_message_key == "wishEditor.seasonJul"
|
||||
|
||||
async def test_create_without_name_message_key_defaults_to_none(
|
||||
self,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""Existing-row simulation: a wish created before this field existed
|
||||
|
||||
(or a free-typed custom name) has no key and must read back `None`,
|
||||
not a synthesized or empty-string value.
|
||||
"""
|
||||
entity = _make(workspace_id=seeded_ids.workspace_id, name="Custom trip")
|
||||
created = await leave_wish_repo.create(entity)
|
||||
assert created.name_message_key is None
|
||||
|
||||
fetched = await leave_wish_repo.get_by_id(created.id)
|
||||
assert fetched is not None
|
||||
assert fetched.name_message_key is None
|
||||
|
||||
async def test_update_round_trips_name_message_key(
|
||||
self,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
entity = _make(workspace_id=seeded_ids.workspace_id, name="Jul", name_message_key=None)
|
||||
created = await leave_wish_repo.create(entity)
|
||||
|
||||
created.name_message_key = "wishEditor.seasonJul"
|
||||
updated = await leave_wish_repo.update(created.id, created)
|
||||
assert updated.name_message_key == "wishEditor.seasonJul"
|
||||
|
||||
fetched = await leave_wish_repo.get_by_id(created.id)
|
||||
assert fetched is not None
|
||||
assert fetched.name_message_key == "wishEditor.seasonJul"
|
||||
|
||||
async def test_create_preserves_all_fields(
|
||||
self,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
@@ -902,6 +953,105 @@ class TestLeaveWishService:
|
||||
assert result.parent_mode == "either"
|
||||
assert result.parent_personal_data_uuid is None
|
||||
|
||||
# -- name_message_key (fixes the "Jul"/"Höstlov" always-Swedish bug) ---
|
||||
|
||||
async def test_create_passes_through_name_message_key(
|
||||
self,
|
||||
leave_wish_service: LeaveWishService,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
result = await leave_wish_service.create(
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
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,
|
||||
first_year=2027,
|
||||
)
|
||||
assert result.name_message_key == "wishEditor.seasonJul"
|
||||
|
||||
async def test_update_without_changing_name_preserves_name_message_key(
|
||||
self,
|
||||
leave_wish_service: LeaveWishService,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
entity = _make(
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
name="Jul",
|
||||
name_message_key="wishEditor.seasonJul",
|
||||
)
|
||||
created = await leave_wish_repo.create(entity)
|
||||
|
||||
result = await leave_wish_service.update(leave_wish_id=created.id, target_weeks=5)
|
||||
assert result.name_message_key == "wishEditor.seasonJul"
|
||||
assert result.name == "Jul"
|
||||
|
||||
async def test_update_that_resends_the_same_name_preserves_name_message_key(
|
||||
self,
|
||||
leave_wish_service: LeaveWishService,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""The auto-save flow resends the unchanged `name` on every edit —
|
||||
|
||||
only an *actual* change to the text may clear the key.
|
||||
"""
|
||||
entity = _make(
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
name="Jul",
|
||||
name_message_key="wishEditor.seasonJul",
|
||||
)
|
||||
created = await leave_wish_repo.create(entity)
|
||||
|
||||
result = await leave_wish_service.update(leave_wish_id=created.id, name="Jul")
|
||||
assert result.name_message_key == "wishEditor.seasonJul"
|
||||
|
||||
async def test_update_renaming_clears_name_message_key_even_without_being_told_to(
|
||||
self,
|
||||
leave_wish_service: LeaveWishService,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""Backend enforcement, not caller-trust: renaming clears the key
|
||||
|
||||
even if the caller never mentions `name_message_key` at all — this
|
||||
is the critical guard against a user's custom name silently
|
||||
reverting to a stale preset label.
|
||||
"""
|
||||
entity = _make(
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
name="Jul",
|
||||
name_message_key="wishEditor.seasonJul",
|
||||
)
|
||||
created = await leave_wish_repo.create(entity)
|
||||
|
||||
result = await leave_wish_service.update(leave_wish_id=created.id, name="Vinterresa")
|
||||
assert result.name == "Vinterresa"
|
||||
assert result.name_message_key is None
|
||||
|
||||
async def test_update_can_explicitly_clear_name_message_key(
|
||||
self,
|
||||
leave_wish_service: LeaveWishService,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
entity = _make(
|
||||
workspace_id=seeded_ids.workspace_id,
|
||||
name="Jul",
|
||||
name_message_key="wishEditor.seasonJul",
|
||||
)
|
||||
created = await leave_wish_repo.create(entity)
|
||||
|
||||
result = await leave_wish_service.update(
|
||||
leave_wish_id=created.id, _clear_name_message_key=True
|
||||
)
|
||||
assert result.name_message_key is None
|
||||
assert result.name == "Jul" # unrelated to the clear
|
||||
|
||||
async def test_update_missing_raises(
|
||||
self,
|
||||
leave_wish_service: LeaveWishService,
|
||||
|
||||
@@ -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