Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions cypress/e2e/05-texts.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', '/');
});
});
9 changes: 9 additions & 0 deletions docs-src/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions locale/ar/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"status_unknown": "غير معروف",
"dialog.discard_title": "هل تريد تجاهل التغييرات؟",
"dialog.discard_message": "لم تُحفظ تغييراتك على هذا المصطلح.",
"dialog.leave_message": "لم تُحفظ تغييراتك في هذه الصفحة.",
"dialog.discard": "تجاهل",
"dialog.keep_editing": "متابعة التعديل",
"dialog.confirm": "تأكيد",
Expand Down
1 change: 1 addition & 0 deletions locale/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions locale/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions locale/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions locale/fa/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"status_unknown": "ناشناخته",
"dialog.discard_title": "تغییرات کنار گذاشته شوند؟",
"dialog.discard_message": "تغییرات شما در این واژه ذخیره نشده است.",
"dialog.leave_message": "تغییرات شما در این صفحه ذخیره نشده است.",
"dialog.discard": "کنار گذاشتن",
"dialog.keep_editing": "ادامهٔ ویرایش",
"dialog.confirm": "تأیید",
Expand Down
1 change: 1 addition & 0 deletions locale/fr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions locale/it/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions locale/ja/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"status_unknown": "不明",
"dialog.discard_title": "変更を破棄しますか?",
"dialog.discard_message": "この用語への変更は保存されていません。",
"dialog.leave_message": "このページの変更は保存されていません。",
"dialog.discard": "破棄",
"dialog.keep_editing": "編集を続ける",
"dialog.confirm": "確認",
Expand Down
1 change: 1 addition & 0 deletions locale/pt/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions locale/ru/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"status_unknown": "Неизвестно",
"dialog.discard_title": "Отменить изменения?",
"dialog.discard_message": "Изменения этого термина не сохранены.",
"dialog.leave_message": "Изменения на этой странице не сохранены.",
"dialog.discard": "Отменить изменения",
"dialog.keep_editing": "Продолжить редактирование",
"dialog.confirm": "Подтвердить",
Expand Down
1 change: 1 addition & 0 deletions locale/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"status_unknown": "未知",
"dialog.discard_title": "放弃更改?",
"dialog.discard_message": "您对此词条的更改尚未保存。",
"dialog.leave_message": "您在此页面上的更改尚未保存。",
"dialog.discard": "放弃",
"dialog.keep_editing": "继续编辑",
"dialog.confirm": "确认",
Expand Down
2 changes: 1 addition & 1 deletion src/Modules/Vocabulary/Http/TermApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
2 changes: 1 addition & 1 deletion src/Modules/Vocabulary/Http/TermCrudApiHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
];
}
Expand Down
7 changes: 5 additions & 2 deletions src/frontend/js/shared/components/confirm_dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
export function confirmDiscardChanges(
message: string = t('common.dialog.discard_message')
): Promise<boolean> {
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
Expand Down
61 changes: 61 additions & 0 deletions src/frontend/js/shared/forms/unloadformcheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLAnchorElement>('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.
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
118 changes: 118 additions & 0 deletions tests/frontend/forms/unloadformcheck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<HTMLButtonElement>(`.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<string, string> = {}): 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<HTMLElement>('.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 = '<button type="button">Tab</button>';

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);
});
});
});
Loading