From 81ad0aa110b79039a2af373628495e058dd9e316 Mon Sep 17 00:00:00 2001 From: HugoFara Date: Wed, 4 Nov 2026 16:28:46 +0100 Subject: [PATCH 1/2] fix(api): return the language ID of a term as a number Two handlers passed the LanguageId value object straight to the JSON encoder, so clients received "langId": {}. --- src/Modules/Vocabulary/Http/TermApiController.php | 2 +- src/Modules/Vocabulary/Http/TermCrudApiHandler.php | 2 +- .../backend/Modules/Vocabulary/Http/TermCrudApiHandlerTest.php | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Modules/Vocabulary/Http/TermApiController.php b/src/Modules/Vocabulary/Http/TermApiController.php index 7002d97bd..8f1afe719 100644 --- a/src/Modules/Vocabulary/Http/TermApiController.php +++ b/src/Modules/Vocabulary/Http/TermApiController.php @@ -80,7 +80,7 @@ public function getTermJson(array $params): void 'romanization' => $term->romanization(), 'sentence' => $term->sentence(), 'status' => $term->status()->toInt(), - 'langId' => $term->languageId(), + 'langId' => $term->languageId()->toInt(), ])->send(); } diff --git a/src/Modules/Vocabulary/Http/TermCrudApiHandler.php b/src/Modules/Vocabulary/Http/TermCrudApiHandler.php index 05efc2752..1430d2af9 100644 --- a/src/Modules/Vocabulary/Http/TermCrudApiHandler.php +++ b/src/Modules/Vocabulary/Http/TermCrudApiHandler.php @@ -118,7 +118,7 @@ public function getTerm(int $termId): array 'sentence' => $term->sentence(), 'status' => $term->status()->toInt(), 'statusLabel' => TermStatusService::getStatusName($term->status()->toInt()), - 'langId' => $term->languageId(), + 'langId' => $term->languageId()->toInt(), 'wordCount' => $term->wordCount(), ]; } diff --git a/tests/backend/Modules/Vocabulary/Http/TermCrudApiHandlerTest.php b/tests/backend/Modules/Vocabulary/Http/TermCrudApiHandlerTest.php index 38aeed582..8bb23d047 100644 --- a/tests/backend/Modules/Vocabulary/Http/TermCrudApiHandlerTest.php +++ b/tests/backend/Modules/Vocabulary/Http/TermCrudApiHandlerTest.php @@ -124,6 +124,7 @@ public function testGetTermIncludesAllFields(): void $this->assertArrayHasKey('status', $result); $this->assertArrayHasKey('statusLabel', $result); $this->assertArrayHasKey('langId', $result); + $this->assertIsInt($result['langId']); $this->assertArrayHasKey('wordCount', $result); } From ddf4cf2bb92d4822738540983c0e43714a13b081 Mon Sep 17 00:00:00 2001 From: HugoFara Date: Wed, 4 Nov 2026 16:28:46 +0100 Subject: [PATCH 2/2] fix(ui): ask in the page before a link leaves a changed form Only term forms had the in-app dialog, so every other form fell back to the browser's prompt on a plain link. A click on a link that would replace the page now opens the dialog on all of them. The native prompt stays for what a page cannot intercept: closing or reloading the tab. --- CHANGELOG.md | 9 ++ cypress/e2e/05-texts.cy.ts | 20 +++ docs-src/changelog.md | 9 ++ locale/ar/common.json | 1 + locale/de/common.json | 1 + locale/en/common.json | 1 + locale/es/common.json | 1 + locale/fa/common.json | 1 + locale/fr/common.json | 1 + locale/it/common.json | 1 + locale/ja/common.json | 1 + locale/pt/common.json | 1 + locale/ru/common.json | 1 + locale/zh/common.json | 1 + .../js/shared/components/confirm_dialog.ts | 7 +- .../js/shared/forms/unloadformcheck.ts | 61 +++++++++ tests/frontend/forms/unloadformcheck.test.ts | 118 ++++++++++++++++++ 17 files changed, 233 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a421f9514..26c461593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,15 @@ ones are marked like "v1.0.0-fork". and now persist per user, and none of these values reach the shared defaults any more. +* **Term API returned an empty language.** `GET /api/v1/terms/{id}` gave + `"langId": {}` instead of the language ID. + +### Changed + +* **Leaving a changed form by a link now asks in the page.** The in-app dialog + replaces the browser's prompt on every form, not only term forms. Closing or + reloading the tab still shows the browser's prompt, as nothing else can. + ## [3.8.1-fork] - 2026-11-04 ### Changed in 3.8.1-fork diff --git a/cypress/e2e/05-texts.cy.ts b/cypress/e2e/05-texts.cy.ts index b7c3f8cdc..61c1823d2 100644 --- a/cypress/e2e/05-texts.cy.ts +++ b/cypress/e2e/05-texts.cy.ts @@ -285,3 +285,23 @@ describe('Texts Management', () => { }); }); }); + +describe('Leaving a changed text form', () => { + it('asks in the page before a link discards the text', () => { + startPastedText(); + cy.get('input[name="TxTitle"]').type('Unsaved').blur(); + + // Keeping the edit stays on the form with the text intact. + cy.contains('a', 'Cancel').click(); + cy.get('.confirm-dialog').should('contain.text', 'this page'); + cy.get('.confirm-dialog .button').not('.is-danger').click(); + cy.get('.confirm-dialog').should('not.exist'); + cy.location('pathname').should('eq', '/texts/new'); + cy.get('input[name="TxTitle"]').should('have.value', 'Unsaved'); + + // Discarding leaves, and no native prompt stands in the way. + cy.contains('a', 'Cancel').click(); + cy.get('.confirm-dialog .button.is-danger').click(); + cy.location('pathname').should('eq', '/'); + }); +}); diff --git a/docs-src/changelog.md b/docs-src/changelog.md index faadda95b..0fa0b6bdf 100644 --- a/docs-src/changelog.md +++ b/docs-src/changelog.md @@ -26,6 +26,15 @@ ones are marked like "v1.0.0-fork". and now persist per user, and none of these values reach the shared defaults any more. +* **Term API returned an empty language.** `GET /api/v1/terms/{id}` gave + `"langId": {}` instead of the language ID. + +### Changed + +* **Leaving a changed form by a link now asks in the page.** The in-app dialog + replaces the browser's prompt on every form, not only term forms. Closing or + reloading the tab still shows the browser's prompt, as nothing else can. + ## [3.8.1-fork] - 2026-11-04 ### Changed in 3.8.1-fork diff --git a/locale/ar/common.json b/locale/ar/common.json index 6d5c4b34f..e8348eca3 100644 --- a/locale/ar/common.json +++ b/locale/ar/common.json @@ -48,6 +48,7 @@ "status_unknown": "غير معروف", "dialog.discard_title": "هل تريد تجاهل التغييرات؟", "dialog.discard_message": "لم تُحفظ تغييراتك على هذا المصطلح.", + "dialog.leave_message": "لم تُحفظ تغييراتك في هذه الصفحة.", "dialog.discard": "تجاهل", "dialog.keep_editing": "متابعة التعديل", "dialog.confirm": "تأكيد", diff --git a/locale/de/common.json b/locale/de/common.json index 5a725ed37..25799959d 100644 --- a/locale/de/common.json +++ b/locale/de/common.json @@ -48,6 +48,7 @@ "status_unknown": "Unbekannt", "dialog.discard_title": "Änderungen verwerfen?", "dialog.discard_message": "Ihre Änderungen an diesem Begriff wurden nicht gespeichert.", + "dialog.leave_message": "Ihre Änderungen auf dieser Seite wurden nicht gespeichert.", "dialog.discard": "Verwerfen", "dialog.keep_editing": "Weiter bearbeiten", "dialog.confirm": "Bestätigen", diff --git a/locale/en/common.json b/locale/en/common.json index e37ed4477..cd7f10141 100644 --- a/locale/en/common.json +++ b/locale/en/common.json @@ -48,6 +48,7 @@ "status_unknown": "Unknown", "dialog.discard_title": "Discard your changes?", "dialog.discard_message": "Your changes to this term have not been saved.", + "dialog.leave_message": "Your changes on this page have not been saved.", "dialog.discard": "Discard", "dialog.keep_editing": "Keep editing", "dialog.confirm": "Confirm", diff --git a/locale/es/common.json b/locale/es/common.json index 2adabf4b2..2847aedba 100644 --- a/locale/es/common.json +++ b/locale/es/common.json @@ -48,6 +48,7 @@ "status_unknown": "Desconocida", "dialog.discard_title": "¿Descartar los cambios?", "dialog.discard_message": "Los cambios en este término no se han guardado.", + "dialog.leave_message": "Los cambios en esta página no se han guardado.", "dialog.discard": "Descartar", "dialog.keep_editing": "Seguir editando", "dialog.confirm": "Confirmar", diff --git a/locale/fa/common.json b/locale/fa/common.json index fcae0eb58..3afcbeaf3 100644 --- a/locale/fa/common.json +++ b/locale/fa/common.json @@ -48,6 +48,7 @@ "status_unknown": "ناشناخته", "dialog.discard_title": "تغییرات کنار گذاشته شوند؟", "dialog.discard_message": "تغییرات شما در این واژه ذخیره نشده است.", + "dialog.leave_message": "تغییرات شما در این صفحه ذخیره نشده است.", "dialog.discard": "کنار گذاشتن", "dialog.keep_editing": "ادامهٔ ویرایش", "dialog.confirm": "تأیید", diff --git a/locale/fr/common.json b/locale/fr/common.json index 22c103942..79d705af7 100644 --- a/locale/fr/common.json +++ b/locale/fr/common.json @@ -48,6 +48,7 @@ "status_unknown": "Inconnue", "dialog.discard_title": "Abandonner vos modifications ?", "dialog.discard_message": "Vos modifications de ce terme n'ont pas été enregistrées.", + "dialog.leave_message": "Vos modifications sur cette page n'ont pas été enregistrées.", "dialog.discard": "Abandonner", "dialog.keep_editing": "Continuer à modifier", "dialog.confirm": "Confirmer", diff --git a/locale/it/common.json b/locale/it/common.json index f5b5a0333..48ec7534f 100644 --- a/locale/it/common.json +++ b/locale/it/common.json @@ -48,6 +48,7 @@ "status_unknown": "Sconosciuta", "dialog.discard_title": "Annullare le modifiche?", "dialog.discard_message": "Le modifiche a questo termine non sono state salvate.", + "dialog.leave_message": "Le modifiche in questa pagina non sono state salvate.", "dialog.discard": "Annulla modifiche", "dialog.keep_editing": "Continua a modificare", "dialog.confirm": "Conferma", diff --git a/locale/ja/common.json b/locale/ja/common.json index e528199d8..baba29129 100644 --- a/locale/ja/common.json +++ b/locale/ja/common.json @@ -48,6 +48,7 @@ "status_unknown": "不明", "dialog.discard_title": "変更を破棄しますか?", "dialog.discard_message": "この用語への変更は保存されていません。", + "dialog.leave_message": "このページの変更は保存されていません。", "dialog.discard": "破棄", "dialog.keep_editing": "編集を続ける", "dialog.confirm": "確認", diff --git a/locale/pt/common.json b/locale/pt/common.json index 29dcd0941..1b3dec975 100644 --- a/locale/pt/common.json +++ b/locale/pt/common.json @@ -48,6 +48,7 @@ "status_unknown": "Desconhecida", "dialog.discard_title": "Descartar as alterações?", "dialog.discard_message": "As alterações neste termo não foram guardadas.", + "dialog.leave_message": "As alterações nesta página não foram guardadas.", "dialog.discard": "Descartar", "dialog.keep_editing": "Continuar a editar", "dialog.confirm": "Confirmar", diff --git a/locale/ru/common.json b/locale/ru/common.json index 7f8d7f4da..de0cca44e 100644 --- a/locale/ru/common.json +++ b/locale/ru/common.json @@ -48,6 +48,7 @@ "status_unknown": "Неизвестно", "dialog.discard_title": "Отменить изменения?", "dialog.discard_message": "Изменения этого термина не сохранены.", + "dialog.leave_message": "Изменения на этой странице не сохранены.", "dialog.discard": "Отменить изменения", "dialog.keep_editing": "Продолжить редактирование", "dialog.confirm": "Подтвердить", diff --git a/locale/zh/common.json b/locale/zh/common.json index f76264f73..c4dfafab3 100644 --- a/locale/zh/common.json +++ b/locale/zh/common.json @@ -48,6 +48,7 @@ "status_unknown": "未知", "dialog.discard_title": "放弃更改?", "dialog.discard_message": "您对此词条的更改尚未保存。", + "dialog.leave_message": "您在此页面上的更改尚未保存。", "dialog.discard": "放弃", "dialog.keep_editing": "继续编辑", "dialog.confirm": "确认", diff --git a/src/frontend/js/shared/components/confirm_dialog.ts b/src/frontend/js/shared/components/confirm_dialog.ts index cf9b7fc78..d9ee63548 100644 --- a/src/frontend/js/shared/components/confirm_dialog.ts +++ b/src/frontend/js/shared/components/confirm_dialog.ts @@ -338,12 +338,15 @@ export async function promptDialog( /** * Ask whether to throw away unsaved changes. * + * @param message What is about to be lost; defaults to the term form's wording * @returns Promise resolving to true if the changes can be discarded */ -export function confirmDiscardChanges(): Promise { +export function confirmDiscardChanges( + message: string = t('common.dialog.discard_message') +): Promise { return confirmDialog({ title: t('common.dialog.discard_title'), - message: t('common.dialog.discard_message'), + message, confirmLabel: t('common.dialog.discard'), cancelLabel: t('common.dialog.keep_editing'), danger: true diff --git a/src/frontend/js/shared/forms/unloadformcheck.ts b/src/frontend/js/shared/forms/unloadformcheck.ts index 4ddf29c24..f56ad354b 100644 --- a/src/frontend/js/shared/forms/unloadformcheck.ts +++ b/src/frontend/js/shared/forms/unloadformcheck.ts @@ -10,6 +10,61 @@ */ import { setupTagChangeTracking } from '@shared/components/tagify_tags'; +import { confirmDiscardChanges } from '@shared/components/confirm_dialog'; +import { t } from '@shared/i18n/translator'; + +let linksGuarded = false; + +/** + * Whether following a link would replace the current page. + * + * @param link The clicked link + * @returns False for new tabs, downloads, in-page anchors and non-web links + */ +function replacesThePage(link: HTMLAnchorElement): boolean { + if ((link.target && link.target !== '_self') || link.hasAttribute('download')) { + return false; + } + const url = new URL(link.href); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return false; + } + const samePage = url.pathname === window.location.pathname + && url.search === window.location.search; + return !(samePage && link.href.includes('#')); +} + +/** + * Ask in the page before a link discards a modified form. + * + * Runs when the click reaches the document, so a link whose own handler + * cancelled it (a tab, a dialog opener) never asks. A browser cannot show this + * dialog while it unloads, so closing or reloading the tab, and scripts that + * navigate by themselves, still get the native prompt from `beforeunload`. + * + * @param event The click + */ +function askBeforeFollowingLink(event: MouseEvent): void { + if ( + !lwtFormCheck.dirty || event.defaultPrevented || event.button !== 0 + || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey + || !(event.target instanceof Element) + ) { + return; + } + const link = event.target.closest('a[href]'); + if (!link || !replacesThePage(link)) { + return; + } + + event.preventDefault(); + void confirmDiscardChanges(t('common.dialog.leave_message')).then((leave) => { + if (leave) { + lwtFormCheck.resetDirty(); + window.location.assign(link.href); + } + }); +} /** * Keeps track of a modified form. @@ -77,6 +132,12 @@ export const lwtFormCheck = { el.addEventListener('click', lwtFormCheck.resetDirty); }); + // Ask in the page when a link would leave it + if (!linksGuarded) { + linksGuarded = true; + document.addEventListener('click', askBeforeFollowingLink); + } + // Warn before unload window.addEventListener('beforeunload', (e) => { const message = lwtFormCheck.isDirtyMessage(); diff --git a/tests/frontend/forms/unloadformcheck.test.ts b/tests/frontend/forms/unloadformcheck.test.ts index 28dd6d4d8..c59546c08 100644 --- a/tests/frontend/forms/unloadformcheck.test.ts +++ b/tests/frontend/forms/unloadformcheck.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { lwtFormCheck, } from '../../../src/frontend/js/shared/forms/unloadformcheck'; +import { settle } from '../helpers/confirm_answers'; describe('unloadformcheck.ts', () => { beforeEach(() => { @@ -312,4 +313,121 @@ describe('unloadformcheck.ts', () => { expect(result).toBeUndefined(); }); }); + + // =========================================================================== + // Links on a changed form + // =========================================================================== + + describe('following a link from a changed form', () => { + const assign = vi.fn(); + + function dialogButton(selector: string): HTMLButtonElement { + const found = document.querySelector(`.confirm-dialog ${selector}`); + if (!found) throw new Error(`No button "${selector}"`); + return found; + } + + function click(link: Element, init: MouseEventInit = {}): MouseEvent { + const event = new MouseEvent('click', { bubbles: true, cancelable: true, ...init }); + link.dispatchEvent(event); + return event; + } + + function linkTo(href: string, attributes: Record = {}): HTMLAnchorElement { + const link = document.createElement('a'); + link.href = href; + Object.entries(attributes).forEach(([name, value]) => link.setAttribute(name, value)); + document.body.appendChild(link); + return link; + } + + beforeEach(() => { + vi.stubGlobal('location', Object.assign(new URL(window.location.href), { assign })); + lwtFormCheck.askBeforeExit(); + lwtFormCheck.dirty = true; + }); + + afterEach(async () => { + document.querySelector('.confirm-dialog .button.is-danger')?.click(); + await settle(); + vi.unstubAllGlobals(); + assign.mockClear(); + lwtFormCheck.dirty = false; + }); + + it('asks in the page and stays when the user keeps editing', async () => { + const event = click(linkTo('/texts')); + + expect(event.defaultPrevented).toBe(true); + expect(document.querySelector('.confirm-dialog')).not.toBeNull(); + + dialogButton('.button:not(.is-danger)').click(); + await settle(); + + expect(assign).not.toHaveBeenCalled(); + expect(lwtFormCheck.dirty).toBe(true); + }); + + it('leaves without the native prompt when the user discards', async () => { + const link = linkTo('/texts'); + click(link); + + dialogButton('.button.is-danger').click(); + await settle(); + + expect(assign).toHaveBeenCalledWith(link.href); + expect(lwtFormCheck.dirty).toBe(false); + }); + + it('does not ask when nothing changed', () => { + lwtFormCheck.dirty = false; + + expect(click(linkTo('/texts')).defaultPrevented).toBe(false); + expect(document.querySelector('.confirm-dialog')).toBeNull(); + }); + + it('does not ask for a click on anything but a link', () => { + document.body.innerHTML = ''; + + click(document.querySelector('button') as Element); + + expect(document.querySelector('.confirm-dialog')).toBeNull(); + }); + + it('does not ask when the link handles its own click', () => { + const link = linkTo('/texts'); + link.addEventListener('click', (event) => event.preventDefault()); + + click(link); + + expect(document.querySelector('.confirm-dialog')).toBeNull(); + }); + + it.each([ + ['an in-page anchor', '#section', {}], + ['a new tab', '/texts', { target: '_blank' }], + ['a download', '/export.csv', { download: '' }], + ['a mail link', 'mailto:someone@example.test', {}] + ])('does not ask for %s', (_name, href, attributes) => { + expect(click(linkTo(href, attributes)).defaultPrevented).toBe(false); + expect(document.querySelector('.confirm-dialog')).toBeNull(); + }); + + it.each([ + ['Ctrl', { ctrlKey: true }], + ['Cmd', { metaKey: true }], + ['Shift', { shiftKey: true }] + ])('does not ask for %s-click, which opens elsewhere', (_name, init) => { + expect(click(linkTo('/texts'), init).defaultPrevented).toBe(false); + }); + + it('adds one listener however often forms ask', () => { + lwtFormCheck.askBeforeExit(); + lwtFormCheck.askBeforeExit(); + + click(linkTo('/texts')); + + expect(document.querySelectorAll('.confirm-dialog').length).toBe(1); + }); + }); });