From dc4c0a5edd129bc434aa39f9db70ef9fb0c937ad Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 14 Sep 2026 22:32:54 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL --- backend/app/db/connection.py | 1 + backend/app/entities/leave_wish.py | 12 +- backend/app/models/leave_wish.py | 12 ++ .../sqlite/leave_wish_repository.py | 9 +- backend/app/routers/leave_wishes.py | 5 + backend/app/services/leave_wish_service.py | 17 ++ .../007_add_leave_wish_name_message_key.py | 51 ++++++ .../tests/integration/test_user_journey.py | 78 +++++++++ .../tests/unit/test_leave_wish_repository.py | 150 ++++++++++++++++++ .../components/planning/WishEditor.test.tsx | 89 ++++++++++- .../src/components/planning/WishEditor.tsx | 54 +++++-- frontend/src/i18n/LanguageContext.test.tsx | 17 ++ frontend/src/i18n/LanguageContext.tsx | 16 ++ frontend/src/i18n/index.ts | 1 + frontend/src/services/api.test.ts | 93 +++++++++++ frontend/src/services/api.ts | 4 + frontend/src/types/api.ts | 4 + frontend/src/types/entities.ts | 6 + frontend/src/utils/wishName.test.ts | 32 ++++ frontend/src/utils/wishName.ts | 34 ++++ 20 files changed, 668 insertions(+), 17 deletions(-) create mode 100644 backend/migrations/versions/007_add_leave_wish_name_message_key.py create mode 100644 frontend/src/utils/wishName.test.ts create mode 100644 frontend/src/utils/wishName.ts diff --git a/backend/app/db/connection.py b/backend/app/db/connection.py index a7f236d..9195604 100644 --- a/backend/app/db/connection.py +++ b/backend/app/db/connection.py @@ -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 diff --git a/backend/app/entities/leave_wish.py b/backend/app/entities/leave_wish.py index 17c6794..1e72732 100644 --- a/backend/app/entities/leave_wish.py +++ b/backend/app/entities/leave_wish.py @@ -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 diff --git a/backend/app/models/leave_wish.py b/backend/app/models/leave_wish.py index 17915eb..ab8bcd8 100644 --- a/backend/app/models/leave_wish.py +++ b/backend/app/models/leave_wish.py @@ -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, diff --git a/backend/app/repositories_impl/sqlite/leave_wish_repository.py b/backend/app/repositories_impl/sqlite/leave_wish_repository.py index 8a5ce25..f91ec7c 100644 --- a/backend/app/repositories_impl/sqlite/leave_wish_repository.py +++ b/backend/app/repositories_impl/sqlite/leave_wish_repository.py @@ -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, diff --git a/backend/app/routers/leave_wishes.py b/backend/app/routers/leave_wishes.py index e5276c3..83642d8 100644 --- a/backend/app/routers/leave_wishes.py +++ b/backend/app/routers/leave_wishes.py @@ -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 diff --git a/backend/app/services/leave_wish_service.py b/backend/app/services/leave_wish_service.py index 60bad39..4d9ac06 100644 --- a/backend/app/services/leave_wish_service.py +++ b/backend/app/services/leave_wish_service.py @@ -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: diff --git a/backend/migrations/versions/007_add_leave_wish_name_message_key.py b/backend/migrations/versions/007_add_leave_wish_name_message_key.py new file mode 100644 index 0000000..55fd4e7 --- /dev/null +++ b/backend/migrations/versions/007_add_leave_wish_name_message_key.py @@ -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)) diff --git a/backend/tests/integration/test_user_journey.py b/backend/tests/integration/test_user_journey.py index 3298391..43d2483 100644 --- a/backend/tests/integration/test_user_journey.py +++ b/backend/tests/integration/test_user_journey.py @@ -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 diff --git a/backend/tests/unit/test_leave_wish_repository.py b/backend/tests/unit/test_leave_wish_repository.py index e812fc1..8c1311d 100644 --- a/backend/tests/unit/test_leave_wish_repository.py +++ b/backend/tests/unit/test_leave_wish_repository.py @@ -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, diff --git a/frontend/src/components/planning/WishEditor.test.tsx b/frontend/src/components/planning/WishEditor.test.tsx index 17573cd..7f5a7a0 100644 --- a/frontend/src/components/planning/WishEditor.test.tsx +++ b/frontend/src/components/planning/WishEditor.test.tsx @@ -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() + }) + }) }) diff --git a/frontend/src/components/planning/WishEditor.tsx b/frontend/src/components/planning/WishEditor.tsx index ea5204b..dea3eb2 100644 --- a/frontend/src/components/planning/WishEditor.tsx +++ b/frontend/src/components/planning/WishEditor.tsx @@ -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 = {} 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({ ) : (
    {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({ 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) })} />