diff --git a/CHANGELOG.md b/CHANGELOG.md
index b79775003..5d3dbaec3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,8 +15,12 @@ ones are marked like "v1.0.0-fork".
### Fixed in Unreleased
-* The unsaved-changes prompt in the term dialogs is now an in-app dialog that
- follows the theme and the interface language, instead of the browser's.
+* Confirmation prompts are now an in-app dialog that follows the theme and the
+ interface language, instead of the browser's, and prompts that were English
+ in every locale are translated. The browser's own dialog remains only as a
+ fallback, and for its warning when leaving a page with unsaved changes.
+
+* Cancelling a database restore no longer leaves the page showing "restoring".
* The "Email not verified" banner no longer follows accounts that have no
email address.
diff --git a/cypress/e2e/13-books.cy.ts b/cypress/e2e/13-books.cy.ts
index d1ba087bd..0d9ccc786 100644
--- a/cypress/e2e/13-books.cy.ts
+++ b/cypress/e2e/13-books.cy.ts
@@ -97,8 +97,8 @@ describe('Books', () => {
cy.get('table tbody tr', { timeout: 10000 }).should('have.length.at.least', 1);
cy.contains('table tbody tr', 'EPUB').should('exist');
- cy.on('window:confirm', () => true);
cy.contains('table tbody tr', 'EPUB').find('button.is-danger').click();
+ cy.get('.confirm-dialog .button.is-danger').click();
cy.get('.notification.is-info', { timeout: 10000 }).should('be.visible');
diff --git a/cypress/e2e/14-dictionaries.cy.ts b/cypress/e2e/14-dictionaries.cy.ts
index 0585a386c..a93a89985 100644
--- a/cypress/e2e/14-dictionaries.cy.ts
+++ b/cypress/e2e/14-dictionaries.cy.ts
@@ -90,8 +90,14 @@ describe('Local dictionaries', () => {
cy.visit(`/languages/${LANG_ID}/dictionaries`);
cy.waitForAlpine();
- cy.on('window:confirm', () => true);
+ // Declining leaves it in place.
cy.contains('table tbody tr', 'Doomed Dict').find('button.is-danger').click();
+ cy.get('.confirm-dialog').contains('button', 'Cancel').click();
+ cy.get('.confirm-dialog').should('not.exist');
+ cy.contains('table tbody tr', 'Doomed Dict').should('exist');
+
+ cy.contains('table tbody tr', 'Doomed Dict').find('button.is-danger').click();
+ cy.get('.confirm-dialog .button.is-danger').click();
cy.contains('table tbody tr', 'Doomed Dict').should('not.exist');
diff --git a/locale/ar/common.json b/locale/ar/common.json
index ba3101e3d..6196822a6 100644
--- a/locale/ar/common.json
+++ b/locale/ar/common.json
@@ -49,5 +49,19 @@
"dialog.discard_title": "هل تريد تجاهل التغييرات؟",
"dialog.discard_message": "لم تُحفظ تغييراتك على هذا المصطلح.",
"dialog.discard": "تجاهل",
- "dialog.keep_editing": "متابعة التعديل"
+ "dialog.keep_editing": "متابعة التعديل",
+ "dialog.confirm": "تأكيد",
+ "dialog.are_you_sure": "هل أنت متأكد؟",
+ "dialog.delete_term": "هل تريد حذف هذا المصطلح؟",
+ "dialog.mark_all_known": "هل تريد وضع علامة «معروفة جيدًا» على كل الكلمات غير المعروفة؟",
+ "dialog.mark_all_ignored": "هل تريد وضع علامة «متجاهلة» على كل الكلمات غير المعروفة؟",
+ "dialog.delete_feed": "هل تريد حذف الموجز «{name}»؟",
+ "dialog.delete_selected_feeds": "هل تريد حذف الموجزات المحددة؟ ({count})",
+ "dialog.delete_selected_articles": "هل تريد حذف المقالات المحددة؟ ({count})",
+ "dialog.delete_all_articles": "هل تريد حذف كل المقالات من «{name}»؟",
+ "dialog.clear_offline": "هل تريد إزالة كل البيانات غير المتصلة؟ لا يمكن التراجع عن هذا الإجراء.",
+ "dialog.translation_exists": "يبدو أن «{text}» موجودة بالفعل كترجمة. هل تريد إدراجها على أي حال؟",
+ "dialog.records_affected": "السجلات المتأثرة: {count}",
+ "dialog.all_records_warning": "ينطبق هذا على كل السجلات في كل صفحات الاستعلام الحالي.",
+ "dialog.delete_table_set": "هل تريد حذف مجموعة الجداول «{name}»؟ ستُفقد كل بياناتها."
}
diff --git a/locale/de/common.json b/locale/de/common.json
index 4a22daf3f..4263477e2 100644
--- a/locale/de/common.json
+++ b/locale/de/common.json
@@ -49,5 +49,19 @@
"dialog.discard_title": "Änderungen verwerfen?",
"dialog.discard_message": "Ihre Änderungen an diesem Begriff wurden nicht gespeichert.",
"dialog.discard": "Verwerfen",
- "dialog.keep_editing": "Weiter bearbeiten"
+ "dialog.keep_editing": "Weiter bearbeiten",
+ "dialog.confirm": "Bestätigen",
+ "dialog.are_you_sure": "Sind Sie sicher?",
+ "dialog.delete_term": "Diesen Begriff löschen?",
+ "dialog.mark_all_known": "Alle unbekannten Wörter als gut bekannt markieren?",
+ "dialog.mark_all_ignored": "Alle unbekannten Wörter als ignoriert markieren?",
+ "dialog.delete_feed": "Feed „{name}“ löschen?",
+ "dialog.delete_selected_feeds": "Ausgewählte Feeds löschen? ({count})",
+ "dialog.delete_selected_articles": "Ausgewählte Artikel löschen? ({count})",
+ "dialog.delete_all_articles": "Alle Artikel aus „{name}“ löschen?",
+ "dialog.clear_offline": "Alle Offline-Daten entfernen? Dies kann nicht rückgängig gemacht werden.",
+ "dialog.translation_exists": "„{text}“ scheint bereits eine Übersetzung zu sein. Trotzdem einfügen?",
+ "dialog.records_affected": "Betroffene Einträge: {count}",
+ "dialog.all_records_warning": "Dies gilt für alle Einträge auf allen Seiten der aktuellen Abfrage.",
+ "dialog.delete_table_set": "Tabellensatz „{name}“ löschen? Alle seine Daten gehen verloren."
}
diff --git a/locale/en/common.json b/locale/en/common.json
index 1811a1520..a6a8fdd27 100644
--- a/locale/en/common.json
+++ b/locale/en/common.json
@@ -49,5 +49,19 @@
"dialog.discard_title": "Discard your changes?",
"dialog.discard_message": "Your changes to this term have not been saved.",
"dialog.discard": "Discard",
- "dialog.keep_editing": "Keep editing"
+ "dialog.keep_editing": "Keep editing",
+ "dialog.confirm": "Confirm",
+ "dialog.are_you_sure": "Are you sure?",
+ "dialog.delete_term": "Delete this term?",
+ "dialog.mark_all_known": "Mark all unknown words as Well Known?",
+ "dialog.mark_all_ignored": "Mark all unknown words as Ignored?",
+ "dialog.delete_feed": "Delete feed \"{name}\"?",
+ "dialog.delete_selected_feeds": "Delete the selected feeds? ({count})",
+ "dialog.delete_selected_articles": "Delete the selected articles? ({count})",
+ "dialog.delete_all_articles": "Delete all articles from \"{name}\"?",
+ "dialog.clear_offline": "Remove all offline data? This cannot be undone.",
+ "dialog.translation_exists": "\"{text}\" already seems to be a translation. Insert it anyway?",
+ "dialog.records_affected": "Records affected: {count}",
+ "dialog.all_records_warning": "This applies to all records on all pages of the current query.",
+ "dialog.delete_table_set": "Delete table set \"{name}\"? All its data will be lost."
}
diff --git a/locale/es/common.json b/locale/es/common.json
index 50d041d80..3dbc74c13 100644
--- a/locale/es/common.json
+++ b/locale/es/common.json
@@ -49,5 +49,19 @@
"dialog.discard_title": "¿Descartar los cambios?",
"dialog.discard_message": "Los cambios en este término no se han guardado.",
"dialog.discard": "Descartar",
- "dialog.keep_editing": "Seguir editando"
+ "dialog.keep_editing": "Seguir editando",
+ "dialog.confirm": "Confirmar",
+ "dialog.are_you_sure": "¿Está seguro?",
+ "dialog.delete_term": "¿Eliminar este término?",
+ "dialog.mark_all_known": "¿Marcar todas las palabras desconocidas como bien conocidas?",
+ "dialog.mark_all_ignored": "¿Marcar todas las palabras desconocidas como ignoradas?",
+ "dialog.delete_feed": "¿Eliminar el feed «{name}»?",
+ "dialog.delete_selected_feeds": "¿Eliminar los feeds seleccionados? ({count})",
+ "dialog.delete_selected_articles": "¿Eliminar los artículos seleccionados? ({count})",
+ "dialog.delete_all_articles": "¿Eliminar todos los artículos de «{name}»?",
+ "dialog.clear_offline": "¿Eliminar todos los datos sin conexión? Esta acción no se puede deshacer.",
+ "dialog.translation_exists": "«{text}» ya parece ser una traducción. ¿Insertarla de todos modos?",
+ "dialog.records_affected": "Registros afectados: {count}",
+ "dialog.all_records_warning": "Esto se aplica a todos los registros de todas las páginas de la consulta actual.",
+ "dialog.delete_table_set": "¿Eliminar el conjunto de tablas «{name}»? Se perderán todos sus datos."
}
diff --git a/locale/fa/common.json b/locale/fa/common.json
index 3da10a384..e43043b40 100644
--- a/locale/fa/common.json
+++ b/locale/fa/common.json
@@ -49,5 +49,19 @@
"dialog.discard_title": "تغییرات کنار گذاشته شوند؟",
"dialog.discard_message": "تغییرات شما در این واژه ذخیره نشده است.",
"dialog.discard": "کنار گذاشتن",
- "dialog.keep_editing": "ادامهٔ ویرایش"
+ "dialog.keep_editing": "ادامهٔ ویرایش",
+ "dialog.confirm": "تأیید",
+ "dialog.are_you_sure": "مطمئن هستید؟",
+ "dialog.delete_term": "این واژه حذف شود؟",
+ "dialog.mark_all_known": "همهٔ واژههای ناشناخته بهعنوان «خوب شناختهشده» علامتگذاری شوند؟",
+ "dialog.mark_all_ignored": "همهٔ واژههای ناشناخته بهعنوان «نادیدهگرفتهشده» علامتگذاری شوند؟",
+ "dialog.delete_feed": "خوراک «{name}» حذف شود؟",
+ "dialog.delete_selected_feeds": "خوراکهای انتخابشده حذف شوند؟ ({count})",
+ "dialog.delete_selected_articles": "مقالههای انتخابشده حذف شوند؟ ({count})",
+ "dialog.delete_all_articles": "همهٔ مقالههای «{name}» حذف شوند؟",
+ "dialog.clear_offline": "همهٔ دادههای آفلاین حذف شوند؟ این کار برگشتپذیر نیست.",
+ "dialog.translation_exists": "به نظر میرسد «{text}» از قبل بهعنوان ترجمه وجود دارد. با این حال درج شود؟",
+ "dialog.records_affected": "رکوردهای تأثیرپذیرفته: {count}",
+ "dialog.all_records_warning": "این کار روی همهٔ رکوردها در همهٔ صفحههای پرسوجوی فعلی اعمال میشود.",
+ "dialog.delete_table_set": "مجموعهجدول «{name}» حذف شود؟ همهٔ دادههای آن از دست خواهد رفت."
}
diff --git a/locale/fr/common.json b/locale/fr/common.json
index f9910dd98..a0a7f93c0 100644
--- a/locale/fr/common.json
+++ b/locale/fr/common.json
@@ -49,5 +49,19 @@
"dialog.discard_title": "Abandonner vos modifications ?",
"dialog.discard_message": "Vos modifications de ce terme n'ont pas été enregistrées.",
"dialog.discard": "Abandonner",
- "dialog.keep_editing": "Continuer à modifier"
+ "dialog.keep_editing": "Continuer à modifier",
+ "dialog.confirm": "Confirmer",
+ "dialog.are_you_sure": "Êtes-vous sûr ?",
+ "dialog.delete_term": "Supprimer ce terme ?",
+ "dialog.mark_all_known": "Marquer tous les mots inconnus comme bien connus ?",
+ "dialog.mark_all_ignored": "Marquer tous les mots inconnus comme ignorés ?",
+ "dialog.delete_feed": "Supprimer le flux « {name} » ?",
+ "dialog.delete_selected_feeds": "Supprimer les flux sélectionnés ? ({count})",
+ "dialog.delete_selected_articles": "Supprimer les articles sélectionnés ? ({count})",
+ "dialog.delete_all_articles": "Supprimer tous les articles de « {name} » ?",
+ "dialog.clear_offline": "Supprimer toutes les données hors ligne ? Cette action est irréversible.",
+ "dialog.translation_exists": "« {text} » semble déjà être une traduction. L'insérer quand même ?",
+ "dialog.records_affected": "Enregistrements concernés : {count}",
+ "dialog.all_records_warning": "Cela s'applique à tous les enregistrements de toutes les pages de la requête en cours.",
+ "dialog.delete_table_set": "Supprimer le jeu de tables « {name} » ? Toutes ses données seront perdues."
}
diff --git a/locale/it/common.json b/locale/it/common.json
index fc82e3dfe..3e36b7bdf 100644
--- a/locale/it/common.json
+++ b/locale/it/common.json
@@ -49,5 +49,19 @@
"dialog.discard_title": "Annullare le modifiche?",
"dialog.discard_message": "Le modifiche a questo termine non sono state salvate.",
"dialog.discard": "Annulla modifiche",
- "dialog.keep_editing": "Continua a modificare"
+ "dialog.keep_editing": "Continua a modificare",
+ "dialog.confirm": "Conferma",
+ "dialog.are_you_sure": "Sei sicuro?",
+ "dialog.delete_term": "Eliminare questo termine?",
+ "dialog.mark_all_known": "Segnare tutte le parole sconosciute come ben note?",
+ "dialog.mark_all_ignored": "Segnare tutte le parole sconosciute come ignorate?",
+ "dialog.delete_feed": "Eliminare il feed «{name}»?",
+ "dialog.delete_selected_feeds": "Eliminare i feed selezionati? ({count})",
+ "dialog.delete_selected_articles": "Eliminare gli articoli selezionati? ({count})",
+ "dialog.delete_all_articles": "Eliminare tutti gli articoli di «{name}»?",
+ "dialog.clear_offline": "Rimuovere tutti i dati offline? L'operazione non può essere annullata.",
+ "dialog.translation_exists": "«{text}» sembra essere già una traduzione. Inserirla comunque?",
+ "dialog.records_affected": "Record interessati: {count}",
+ "dialog.all_records_warning": "Vale per tutti i record di tutte le pagine della query corrente.",
+ "dialog.delete_table_set": "Eliminare il set di tabelle «{name}»? Tutti i suoi dati andranno persi."
}
diff --git a/locale/ja/common.json b/locale/ja/common.json
index 904099a6f..d36656d21 100644
--- a/locale/ja/common.json
+++ b/locale/ja/common.json
@@ -49,5 +49,19 @@
"dialog.discard_title": "変更を破棄しますか?",
"dialog.discard_message": "この用語への変更は保存されていません。",
"dialog.discard": "破棄",
- "dialog.keep_editing": "編集を続ける"
+ "dialog.keep_editing": "編集を続ける",
+ "dialog.confirm": "確認",
+ "dialog.are_you_sure": "よろしいですか?",
+ "dialog.delete_term": "この用語を削除しますか?",
+ "dialog.mark_all_known": "未知の単語をすべて既知としてマークしますか?",
+ "dialog.mark_all_ignored": "未知の単語をすべて無視としてマークしますか?",
+ "dialog.delete_feed": "フィード「{name}」を削除しますか?",
+ "dialog.delete_selected_feeds": "選択したフィードを削除しますか?({count})",
+ "dialog.delete_selected_articles": "選択した記事を削除しますか?({count})",
+ "dialog.delete_all_articles": "「{name}」のすべての記事を削除しますか?",
+ "dialog.clear_offline": "オフラインデータをすべて削除しますか?この操作は元に戻せません。",
+ "dialog.translation_exists": "「{text}」はすでに訳語として登録されているようです。それでも挿入しますか?",
+ "dialog.records_affected": "対象レコード数: {count}",
+ "dialog.all_records_warning": "現在のクエリの全ページにあるすべてのレコードが対象です。",
+ "dialog.delete_table_set": "テーブルセット「{name}」を削除しますか?すべてのデータが失われます。"
}
diff --git a/locale/pt/common.json b/locale/pt/common.json
index 0fc0075f3..aad613390 100644
--- a/locale/pt/common.json
+++ b/locale/pt/common.json
@@ -49,5 +49,19 @@
"dialog.discard_title": "Descartar as alterações?",
"dialog.discard_message": "As alterações neste termo não foram guardadas.",
"dialog.discard": "Descartar",
- "dialog.keep_editing": "Continuar a editar"
+ "dialog.keep_editing": "Continuar a editar",
+ "dialog.confirm": "Confirmar",
+ "dialog.are_you_sure": "Tem a certeza?",
+ "dialog.delete_term": "Eliminar este termo?",
+ "dialog.mark_all_known": "Marcar todas as palavras desconhecidas como bem conhecidas?",
+ "dialog.mark_all_ignored": "Marcar todas as palavras desconhecidas como ignoradas?",
+ "dialog.delete_feed": "Eliminar o feed «{name}»?",
+ "dialog.delete_selected_feeds": "Eliminar os feeds selecionados? ({count})",
+ "dialog.delete_selected_articles": "Eliminar os artigos selecionados? ({count})",
+ "dialog.delete_all_articles": "Eliminar todos os artigos de «{name}»?",
+ "dialog.clear_offline": "Remover todos os dados offline? Esta ação não pode ser anulada.",
+ "dialog.translation_exists": "«{text}» já parece ser uma tradução. Inserir mesmo assim?",
+ "dialog.records_affected": "Registos afetados: {count}",
+ "dialog.all_records_warning": "Isto aplica-se a todos os registos de todas as páginas da consulta atual.",
+ "dialog.delete_table_set": "Eliminar o conjunto de tabelas «{name}»? Todos os seus dados serão perdidos."
}
diff --git a/locale/ru/common.json b/locale/ru/common.json
index b674659bd..2bfd3d640 100644
--- a/locale/ru/common.json
+++ b/locale/ru/common.json
@@ -49,5 +49,19 @@
"dialog.discard_title": "Отменить изменения?",
"dialog.discard_message": "Изменения этого термина не сохранены.",
"dialog.discard": "Отменить изменения",
- "dialog.keep_editing": "Продолжить редактирование"
+ "dialog.keep_editing": "Продолжить редактирование",
+ "dialog.confirm": "Подтвердить",
+ "dialog.are_you_sure": "Вы уверены?",
+ "dialog.delete_term": "Удалить этот термин?",
+ "dialog.mark_all_known": "Отметить все неизвестные слова как хорошо известные?",
+ "dialog.mark_all_ignored": "Отметить все неизвестные слова как игнорируемые?",
+ "dialog.delete_feed": "Удалить ленту «{name}»?",
+ "dialog.delete_selected_feeds": "Удалить выбранные ленты? ({count})",
+ "dialog.delete_selected_articles": "Удалить выбранные статьи? ({count})",
+ "dialog.delete_all_articles": "Удалить все статьи из «{name}»?",
+ "dialog.clear_offline": "Удалить все офлайн-данные? Это действие нельзя отменить.",
+ "dialog.translation_exists": "«{text}», похоже, уже есть среди переводов. Всё равно добавить?",
+ "dialog.records_affected": "Затронуто записей: {count}",
+ "dialog.all_records_warning": "Это относится ко всем записям на всех страницах текущего запроса.",
+ "dialog.delete_table_set": "Удалить набор таблиц «{name}»? Все его данные будут потеряны."
}
diff --git a/locale/zh/common.json b/locale/zh/common.json
index d9182c468..13795c09f 100644
--- a/locale/zh/common.json
+++ b/locale/zh/common.json
@@ -49,5 +49,19 @@
"dialog.discard_title": "放弃更改?",
"dialog.discard_message": "您对此词条的更改尚未保存。",
"dialog.discard": "放弃",
- "dialog.keep_editing": "继续编辑"
+ "dialog.keep_editing": "继续编辑",
+ "dialog.confirm": "确认",
+ "dialog.are_you_sure": "确定吗?",
+ "dialog.delete_term": "删除此词条?",
+ "dialog.mark_all_known": "将所有未知单词标记为熟知?",
+ "dialog.mark_all_ignored": "将所有未知单词标记为已忽略?",
+ "dialog.delete_feed": "删除订阅源“{name}”?",
+ "dialog.delete_selected_feeds": "删除所选订阅源?({count})",
+ "dialog.delete_selected_articles": "删除所选文章?({count})",
+ "dialog.delete_all_articles": "删除“{name}”中的所有文章?",
+ "dialog.clear_offline": "删除所有离线数据?此操作无法撤销。",
+ "dialog.translation_exists": "“{text}”似乎已是一个译文。仍要插入吗?",
+ "dialog.records_affected": "受影响的记录:{count}",
+ "dialog.all_records_warning": "此操作适用于当前查询所有页面上的全部记录。",
+ "dialog.delete_table_set": "删除表集“{name}”?其所有数据都将丢失。"
}
diff --git a/src/Modules/Text/Views/print_alpine.php b/src/Modules/Text/Views/print_alpine.php
index b5eb74f03..691c3724e 100644
--- a/src/Modules/Text/Views/print_alpine.php
+++ b/src/Modules/Text/Views/print_alpine.php
@@ -234,7 +234,7 @@
Edit
diff --git a/src/frontend/css/base/styles.css b/src/frontend/css/base/styles.css
index 127bf11a9..b18ec09de 100644
--- a/src/frontend/css/base/styles.css
+++ b/src/frontend/css/base/styles.css
@@ -3370,6 +3370,10 @@ Searchable Select Component (Alpine.js)
width: calc(100% - 2rem);
}
+#confirm-dialog-message {
+ white-space: pre-line;
+}
+
.confirm-dialog__actions {
gap: 0.5rem;
justify-content: flex-end;
diff --git a/src/frontend/js/modules/admin/pages/settings_form.ts b/src/frontend/js/modules/admin/pages/settings_form.ts
index cb6b66d5e..95aad649c 100644
--- a/src/frontend/js/modules/admin/pages/settings_form.ts
+++ b/src/frontend/js/modules/admin/pages/settings_form.ts
@@ -7,6 +7,8 @@
* @since 3.1.0 Migrated to Alpine.js component
*/
+import { confirmAction, confirmBeforeSubmit } from '@shared/components/confirm_dialog';
+import { t } from '@shared/i18n/translator';
import { onDomReady } from '@shared/utils/dom_ready';
import Alpine from 'alpinejs';
import { lwtFormCheck } from '@shared/forms/unloadformcheck';
@@ -49,9 +51,9 @@ export function settingsFormApp() {
/**
* Handle form submission with confirmation.
*/
- confirmSubmit(event: Event, message: string = 'Are you sure?') {
- if (!confirm(message)) {
- event.preventDefault();
+ confirmSubmit(event: Event, message: string = t('common.dialog.are_you_sure')) {
+ const form = event.target as HTMLFormElement;
+ if (!confirmBeforeSubmit(event, form, () => confirmAction(message))) {
return false;
}
this.isSubmitting = true;
@@ -156,9 +158,8 @@ export function initConfirmSubmitForms(): void {
document.addEventListener('submit', (e) => {
const form = (e.target as HTMLElement).closest('form[data-action="confirm-submit"]');
if (form) {
- const message = form.dataset.confirmMessage || 'Are you sure?';
- if (!confirm(message)) {
- e.preventDefault();
+ const message = form.dataset.confirmMessage || t('common.dialog.are_you_sure');
+ if (!confirmBeforeSubmit(e, form, () => confirmAction(message))) {
return false;
}
// Show loading state on submit button after confirmation
diff --git a/src/frontend/js/modules/admin/pages/table_management.ts b/src/frontend/js/modules/admin/pages/table_management.ts
index 48bef2935..32ccb1779 100644
--- a/src/frontend/js/modules/admin/pages/table_management.ts
+++ b/src/frontend/js/modules/admin/pages/table_management.ts
@@ -9,6 +9,8 @@
* @since 3.0.0
*/
+import { t } from '@shared/i18n/translator';
+import { confirmBeforeSubmit, confirmDeletion } from '@shared/components/confirm_dialog';
import Alpine from 'alpinejs';
interface TableManagementState {
@@ -83,14 +85,9 @@ export function tableManagementApp(): TableManagementState {
return;
}
- const confirmMessage =
- `*** DELETING TABLE SET: ${this.deletePrefix} ***\n\n` +
- `ALL DATA IN THIS TABLE SET WILL BE LOST!\n\n` +
- `Are you sure?`;
-
- if (!confirm(confirmMessage)) {
- event.preventDefault();
- }
+ confirmBeforeSubmit(event, event.target as HTMLFormElement, () =>
+ confirmDeletion(t('common.dialog.delete_table_set', { name: this.deletePrefix }))
+ );
}
};
}
diff --git a/src/frontend/js/modules/admin/pages/user_management.ts b/src/frontend/js/modules/admin/pages/user_management.ts
index 0aba7ca4f..01d8cf963 100644
--- a/src/frontend/js/modules/admin/pages/user_management.ts
+++ b/src/frontend/js/modules/admin/pages/user_management.ts
@@ -13,6 +13,7 @@
* @since 3.4.0
*/
+import { confirmAction, confirmDeletion } from '@shared/components/confirm_dialog';
import Alpine from 'alpinejs';
import { t } from '@shared/i18n/translator';
import {
@@ -195,7 +196,7 @@ export function userManagementData(): UserManagementData {
async toggleRole(user: AdminUser): Promise {
const next = user.isAdmin ? 'user' : 'admin';
- if (!confirm(t('admin.users.confirm_role', { username: user.username, role: next }))) {
+ if (!await confirmAction(t('admin.users.confirm_role', { username: user.username, role: next }))) {
return;
}
this.busyId = user.id;
@@ -210,7 +211,7 @@ export function userManagementData(): UserManagementData {
},
async confirmDelete(user: AdminUser): Promise {
- if (!confirm(t('admin.users.confirm_delete', { username: user.username }))) {
+ if (!await confirmDeletion(t('admin.users.confirm_delete', { username: user.username }))) {
return;
}
this.busyId = user.id;
diff --git a/src/frontend/js/modules/book/pages/book_detail.ts b/src/frontend/js/modules/book/pages/book_detail.ts
index b585a2df5..559bd981c 100644
--- a/src/frontend/js/modules/book/pages/book_detail.ts
+++ b/src/frontend/js/modules/book/pages/book_detail.ts
@@ -9,6 +9,7 @@
* @since 3.4.0
*/
+import { confirmDeletion } from '@shared/components/confirm_dialog';
import Alpine from 'alpinejs';
import { t } from '@shared/i18n/translator';
import { BooksApi, type Book, type BookChapter } from '../api/books_api';
@@ -84,7 +85,7 @@ export function bookDetailData(): BookDetailData {
},
async remove() {
- if (!this.book || !window.confirm(t('book.confirm_delete_book'))) {
+ if (!this.book || !await confirmDeletion(t('book.confirm_delete_book'))) {
return;
}
diff --git a/src/frontend/js/modules/book/pages/book_list.ts b/src/frontend/js/modules/book/pages/book_list.ts
index a8774bc8e..e5e89da6c 100644
--- a/src/frontend/js/modules/book/pages/book_list.ts
+++ b/src/frontend/js/modules/book/pages/book_list.ts
@@ -9,6 +9,7 @@
* @since 3.4.0
*/
+import { confirmDeletion } from '@shared/components/confirm_dialog';
import Alpine from 'alpinejs';
import { t } from '@shared/i18n/translator';
import { LanguagesApi, type LanguageListItem } from '@modules/language/api/languages_api';
@@ -139,7 +140,7 @@ export function bookListData(): BookListData {
},
async confirmDelete(book: Book) {
- if (!window.confirm(t('book.confirm_delete_book'))) {
+ if (!await confirmDeletion(t('book.confirm_delete_book'))) {
return;
}
diff --git a/src/frontend/js/modules/dictionary/pages/dictionary_list.ts b/src/frontend/js/modules/dictionary/pages/dictionary_list.ts
index 8bd85ac24..58b8ef498 100644
--- a/src/frontend/js/modules/dictionary/pages/dictionary_list.ts
+++ b/src/frontend/js/modules/dictionary/pages/dictionary_list.ts
@@ -8,6 +8,7 @@
* @since 3.4.0
*/
+import { confirmDeletion } from '@shared/components/confirm_dialog';
import Alpine from 'alpinejs';
import { t } from '@shared/i18n/translator';
import {
@@ -139,7 +140,7 @@ export function dictionaryListData(): DictionaryListData {
},
async confirmDelete(dict: LocalDictionary) {
- if (!window.confirm(t('dictionary.confirm_delete_dict'))) {
+ if (!await confirmDeletion(t('dictionary.confirm_delete_dict'))) {
return;
}
diff --git a/src/frontend/js/modules/feed/pages/feed_manager_app.ts b/src/frontend/js/modules/feed/pages/feed_manager_app.ts
index 5c45ebd1f..937c11119 100644
--- a/src/frontend/js/modules/feed/pages/feed_manager_app.ts
+++ b/src/frontend/js/modules/feed/pages/feed_manager_app.ts
@@ -8,6 +8,8 @@
* @since 3.0.0
*/
+import { t } from '@shared/i18n/translator';
+import { confirmDeletion } from '@shared/components/confirm_dialog';
import { onDomReady } from '@shared/utils/dom_ready';
import Alpine from 'alpinejs';
import { initFeedManagerStore, getFeedManagerStore } from '../stores/feed_manager_store';
@@ -69,7 +71,7 @@ function feedListComponent() {
},
async deleteFeed(feed: Feed): Promise {
- if (confirm(`Delete feed "${feed.name}"?`)) {
+ if (await confirmDeletion(t('common.dialog.delete_feed', { name: feed.name }))) {
await this.store.deleteFeed(feed.id);
}
},
@@ -84,7 +86,7 @@ function feedListComponent() {
async deleteSelected(): Promise {
if (this.selectedCount === 0) return;
- if (confirm(`Delete ${this.selectedCount} selected feed(s)?`)) {
+ if (await confirmDeletion(t('common.dialog.delete_selected_feeds', { count: this.selectedCount }))) {
await this.store.deleteSelectedFeeds();
}
},
@@ -193,13 +195,13 @@ function articleListComponent() {
async deleteSelected(): Promise {
if (this.selectedCount === 0) return;
- if (confirm(`Delete ${this.selectedCount} selected article(s)?`)) {
+ if (await confirmDeletion(t('common.dialog.delete_selected_articles', { count: this.selectedCount }))) {
await this.store.deleteSelectedArticles();
}
},
async deleteAll(): Promise {
- if (confirm(`Delete ALL articles from "${this.feed?.name}"?`)) {
+ if (await confirmDeletion(t('common.dialog.delete_all_articles', { name: this.feed?.name ?? '' }))) {
await this.store.deleteAllArticles();
}
},
diff --git a/src/frontend/js/modules/language/stores/language_settings.ts b/src/frontend/js/modules/language/stores/language_settings.ts
index 8653757c2..997004f30 100644
--- a/src/frontend/js/modules/language/stores/language_settings.ts
+++ b/src/frontend/js/modules/language/stores/language_settings.ts
@@ -6,6 +6,8 @@
* @since 2.10.0-fork Extracted from legacy/pgm.ts
*/
+import { t as translate } from '@shared/i18n/translator';
+import { confirmAction } from '@shared/components/confirm_dialog';
import { onDomReady } from '@shared/utils/dom_ready';
import { apiPut, getCsrfToken } from '@shared/api/client';
@@ -179,8 +181,7 @@ export async function resetAll(url: string): Promise {
* @param t Text ID
*/
export async function iknowall(t: string | number): Promise {
- const answer = confirm('Are you sure?');
- if (!answer) return;
+ if (!await confirmAction(translate('common.dialog.mark_all_known'))) return;
const textId = typeof t === 'string' ? parseInt(t, 10) : t;
diff --git a/src/frontend/js/modules/tags/pages/tag_list_app.ts b/src/frontend/js/modules/tags/pages/tag_list_app.ts
index 80419f0b6..60c1c56ab 100644
--- a/src/frontend/js/modules/tags/pages/tag_list_app.ts
+++ b/src/frontend/js/modules/tags/pages/tag_list_app.ts
@@ -9,6 +9,7 @@
* @since 3.4.0
*/
+import { confirmDeletion } from '@shared/components/confirm_dialog';
import Alpine from 'alpinejs';
import { t, tn } from '@shared/i18n/translator';
import { readPageConfig } from '@shared/utils/page_config';
@@ -190,7 +191,7 @@ export function tagListAppData(): TagListAppData {
},
async deleteOne(tag: TagRecord): Promise {
- if (!confirm(t('tags.confirm_delete', { text: tag.text }))) {
+ if (!await confirmDeletion(t('tags.confirm_delete', { text: tag.text }))) {
return;
}
this.isBusy = true;
@@ -208,7 +209,7 @@ export function tagListAppData(): TagListAppData {
if (this.selectedIds.length === 0) {
return;
}
- if (!confirm(tn('tags.confirm_delete_selected', this.selectedIds.length))) {
+ if (!await confirmDeletion(tn('tags.confirm_delete_selected', this.selectedIds.length))) {
return;
}
this.isBusy = true;
@@ -224,7 +225,7 @@ export function tagListAppData(): TagListAppData {
},
async deleteAllMatching(): Promise {
- if (!confirm(tn('tags.confirm_delete_all', this.pagination.total))) {
+ if (!await confirmDeletion(tn('tags.confirm_delete_all', this.pagination.total))) {
return;
}
this.isBusy = true;
diff --git a/src/frontend/js/modules/text/components/text_reader.ts b/src/frontend/js/modules/text/components/text_reader.ts
index f714dc04f..e84d8c5d8 100644
--- a/src/frontend/js/modules/text/components/text_reader.ts
+++ b/src/frontend/js/modules/text/components/text_reader.ts
@@ -8,6 +8,8 @@
* @since 3.0.0
*/
+import { t } from '@shared/i18n/translator';
+import { confirmAction } from '@shared/components/confirm_dialog';
import Alpine from 'alpinejs';
import type { WordStoreState } from '@modules/vocabulary/stores/word_store';
import { renderText, updateWordStatusInDOM, renderParseWarning, type RenderSettings }
@@ -319,7 +321,7 @@ export function textReaderData(): TextReaderData {
},
async markAllWellKnown(): Promise {
- if (!confirm('Mark all unknown words as Well Known?')) return;
+ if (!await confirmAction(t('common.dialog.mark_all_known'))) return;
this.statusMessage = null;
@@ -350,7 +352,7 @@ export function textReaderData(): TextReaderData {
},
async markAllIgnored(): Promise {
- if (!confirm('Mark all unknown words as Ignored?')) return;
+ if (!await confirmAction(t('common.dialog.mark_all_ignored'))) return;
this.statusMessage = null;
diff --git a/src/frontend/js/modules/text/pages/archived_texts_grouped_app.ts b/src/frontend/js/modules/text/pages/archived_texts_grouped_app.ts
index e117a9786..d150a0ae7 100644
--- a/src/frontend/js/modules/text/pages/archived_texts_grouped_app.ts
+++ b/src/frontend/js/modules/text/pages/archived_texts_grouped_app.ts
@@ -123,9 +123,9 @@ export interface ArchivedTextsGroupedData {
getMarkedCount(langId: number): number;
// Actions
- handleMultiAction(langId: number, event: Event): void;
- handleDelete(event: Event, url: string): void;
- handleRestDelete(event: Event, url: string): void;
+ handleMultiAction(langId: number, event: Event): Promise;
+ handleDelete(event: Event, url: string): Promise;
+ handleRestDelete(event: Event, url: string): Promise;
handlePostAction(event: Event, url: string): void;
// Sorting
@@ -369,7 +369,7 @@ export function archivedTextsGroupedData(): ArchivedTextsGroupedData {
},
// Actions
- handleMultiAction(langId: number, event: Event) {
+ async handleMultiAction(langId: number, event: Event) {
const select = event.target as HTMLSelectElement;
const action = select.value;
if (!action) return;
@@ -414,7 +414,7 @@ export function archivedTextsGroupedData(): ArchivedTextsGroupedData {
// Handle special actions that need confirmation
if (action === 'del') {
- if (!confirmDelete()) {
+ if (!await confirmDelete()) {
select.value = '';
return;
}
@@ -425,16 +425,16 @@ export function archivedTextsGroupedData(): ArchivedTextsGroupedData {
form.submit();
},
- handleDelete(event: Event, url: string) {
+ async handleDelete(event: Event, url: string) {
event.preventDefault();
- if (confirmDelete()) {
+ if (await confirmDelete()) {
window.location.href = url;
}
},
- handleRestDelete(event: Event, url: string) {
+ async handleRestDelete(event: Event, url: string) {
event.preventDefault();
- if (confirmDelete()) {
+ if (await confirmDelete()) {
const headers: Record = {
'X-Requested-With': 'XMLHttpRequest',
};
diff --git a/src/frontend/js/modules/text/pages/text_list.ts b/src/frontend/js/modules/text/pages/text_list.ts
index 61e346ac3..087309e0b 100644
--- a/src/frontend/js/modules/text/pages/text_list.ts
+++ b/src/frontend/js/modules/text/pages/text_list.ts
@@ -203,11 +203,11 @@ function handleResetAll(): void {
/**
* Handle delete confirmation with navigation.
*/
-function handleConfirmDelete(e: Event, actionEl: HTMLElement): void {
+async function handleConfirmDelete(e: Event, actionEl: HTMLElement): Promise {
e.preventDefault();
const url = actionEl.dataset.url;
- if (url && confirmDelete()) {
+ if (url && await confirmDelete()) {
location.href = url;
}
}
diff --git a/src/frontend/js/modules/text/pages/text_print_app.ts b/src/frontend/js/modules/text/pages/text_print_app.ts
index 9c9c546d3..8126f86ed 100644
--- a/src/frontend/js/modules/text/pages/text_print_app.ts
+++ b/src/frontend/js/modules/text/pages/text_print_app.ts
@@ -12,6 +12,7 @@
* @since 3.0.0
*/
+import { confirmAction, confirmDeletion } from '@shared/components/confirm_dialog';
import Alpine from 'alpinejs';
import { initIcons } from '@shared/icons/lucide_icons';
import { apiPost, getCsrfToken } from '@shared/api/client';
@@ -102,8 +103,8 @@ export interface TextPrintAppData {
// Actions
handlePrint(): void;
navigateTo(url: string): void;
- confirmNavigateTo(url: string, message: string): void;
- confirmDeleteAnnotation(textId: number, message: string): void;
+ confirmNavigateTo(url: string, message: string): Promise;
+ confirmDeleteAnnotation(textId: number): Promise;
openWindow(url: string): void;
// CSP-compatible innerHTML setters (use with x-effect)
@@ -512,14 +513,14 @@ export function textPrintAppData(): TextPrintAppData {
window.location.href = url;
},
- confirmNavigateTo(url: string, message: string) {
- if (confirm(message)) {
+ async confirmNavigateTo(url: string, message: string) {
+ if (await confirmAction(message)) {
window.location.href = url;
}
},
- confirmDeleteAnnotation(textId: number, message: string) {
- if (confirm(message)) {
+ async confirmDeleteAnnotation(textId: number) {
+ if (await confirmDeletion()) {
const headers: Record = {
'Content-Type': 'application/json'
};
diff --git a/src/frontend/js/modules/text/pages/texts_grouped_app.ts b/src/frontend/js/modules/text/pages/texts_grouped_app.ts
index 7b49f1b0b..7c484fec1 100644
--- a/src/frontend/js/modules/text/pages/texts_grouped_app.ts
+++ b/src/frontend/js/modules/text/pages/texts_grouped_app.ts
@@ -99,9 +99,9 @@ export interface TextsGroupedData {
isTextMarked(textId: number): boolean;
// Actions
- handleMultiAction(event: Event): void;
+ handleMultiAction(event: Event): Promise;
submitBulkApiAction(action: 'archive' | 'delete', ids: number[]): Promise;
- handleRestDelete(event: Event, url: string): void;
+ handleRestDelete(event: Event, url: string): Promise;
handleRestDeleteFromEvent(event: Event): void;
handlePostAction(event: Event, url: string): void;
handlePostActionFromEvent(event: Event): void;
@@ -242,7 +242,7 @@ export function textsGroupedData(): TextsGroupedData {
},
// Actions
- handleMultiAction(event: Event) {
+ async handleMultiAction(event: Event) {
const select = event.target as HTMLSelectElement;
const action = select.value;
if (!action) return;
@@ -257,7 +257,7 @@ export function textsGroupedData(): TextsGroupedData {
// a configurable server (a form POST would hit the page origin instead).
// Tag / review / reparse stay on the form path below.
if (action === 'arch' || action === 'del') {
- if (action === 'del' && !confirmDelete()) {
+ if (action === 'del' && !await confirmDelete()) {
select.value = '';
return;
}
@@ -313,9 +313,9 @@ export function textsGroupedData(): TextsGroupedData {
window.location.reload();
},
- handleRestDelete(event: Event, url: string) {
+ async handleRestDelete(event: Event, url: string) {
event.preventDefault();
- if (confirmDelete()) {
+ if (await confirmDelete()) {
const headers: Record = {
'X-Requested-With': 'XMLHttpRequest',
};
@@ -356,7 +356,7 @@ export function textsGroupedData(): TextsGroupedData {
/** CSP-friendly: read data-url from element */
handleRestDeleteFromEvent(event: Event) {
const el = event.currentTarget as HTMLElement;
- this.handleRestDelete(event, el.dataset.url || '');
+ void this.handleRestDelete(event, el.dataset.url || '');
},
/** CSP-friendly: read data-url from element */
diff --git a/src/frontend/js/modules/vocabulary/components/word_modal.ts b/src/frontend/js/modules/vocabulary/components/word_modal.ts
index 1dbeb9b8c..16f384749 100644
--- a/src/frontend/js/modules/vocabulary/components/word_modal.ts
+++ b/src/frontend/js/modules/vocabulary/components/word_modal.ts
@@ -9,7 +9,7 @@
* @since 3.0.0
*/
-import { confirmDiscardChanges } from '@shared/components/confirm_dialog';
+import { confirmDeletion, confirmDiscardChanges } from '@shared/components/confirm_dialog';
import Alpine from 'alpinejs';
import type { WordStoreState, WordData } from '../stores/word_store';
import type { WordFormStoreState } from '../stores/word_form_store';
@@ -323,7 +323,7 @@ export function wordModalData(): WordModalData {
const word = this.word;
if (!word) return;
- if (confirm('Delete this term?')) {
+ if (await confirmDeletion(t('common.dialog.delete_term'))) {
await this.store.deleteWord(word.hex);
}
},
diff --git a/src/frontend/js/modules/vocabulary/components/word_popover.ts b/src/frontend/js/modules/vocabulary/components/word_popover.ts
index 6841f4cb6..2d275405d 100644
--- a/src/frontend/js/modules/vocabulary/components/word_popover.ts
+++ b/src/frontend/js/modules/vocabulary/components/word_popover.ts
@@ -8,6 +8,8 @@
* @since 3.0.0
*/
+import { t } from '@shared/i18n/translator';
+import { confirmDeletion } from '@shared/components/confirm_dialog';
import Alpine from 'alpinejs';
import type { WordStoreState, WordData } from '../stores/word_store';
import { speechDispatcher } from '@shared/utils/user_interactions';
@@ -263,7 +265,7 @@ export function wordPopoverData(): WordPopoverData {
const word = this.word;
if (!word) return;
- if (confirm('Delete this term?')) {
+ if (await confirmDeletion(t('common.dialog.delete_term'))) {
await this.store.deleteWord(word.hex);
}
},
diff --git a/src/frontend/js/modules/vocabulary/pages/word_list_app.ts b/src/frontend/js/modules/vocabulary/pages/word_list_app.ts
index 831d3489e..7fd33d403 100644
--- a/src/frontend/js/modules/vocabulary/pages/word_list_app.ts
+++ b/src/frontend/js/modules/vocabulary/pages/word_list_app.ts
@@ -11,6 +11,7 @@
* @since 3.0.0
*/
+import { confirmAction, confirmDeletion } from '@shared/components/confirm_dialog';
import Alpine from 'alpinejs';
import { initIcons } from '@shared/icons/lucide_icons';
import { t, tn } from '@shared/i18n/translator';
@@ -504,7 +505,7 @@ export function wordListData(): WordListData {
// Confirm destructive actions
if (action === 'del') {
- if (!confirm(tn('vocabulary.list.confirm_delete', ids.length))) {
+ if (!await confirmDeletion(tn('vocabulary.list.confirm_delete', ids.length))) {
select.value = '';
return;
}
@@ -562,7 +563,7 @@ export function wordListData(): WordListData {
}
if (
- !confirm(
+ !await confirmAction(
tn('vocabulary.list.confirm_apply_all', this.pagination.total)
)
) {
diff --git a/src/frontend/js/modules/vocabulary/services/translation_api.ts b/src/frontend/js/modules/vocabulary/services/translation_api.ts
index 9c4a652bb..128240b4a 100644
--- a/src/frontend/js/modules/vocabulary/services/translation_api.ts
+++ b/src/frontend/js/modules/vocabulary/services/translation_api.ts
@@ -6,6 +6,8 @@
* @since 1.6.16-fork
*/
+import { t } from '@shared/i18n/translator';
+import { confirmAction } from '@shared/components/confirm_dialog';
import { iconHtml } from '@shared/icons/icons';
import { escapeHtml } from '@shared/utils/html_utils';
@@ -65,7 +67,7 @@ export function deleteTranslation(): void {
}
}
-export function addTranslation(s: string): void {
+export async function addTranslation(s: string): Promise {
let frame: LwtFrame | undefined = (window.parent as Window & { frames: FramesCollection }).frames.ro;
if (frame === undefined) {
frame = window.opener as LwtFrame | undefined;
@@ -88,10 +90,7 @@ export function addTranslation(s: string): void {
word_trans.value = oldValue + ' / ' + s;
frame.lwtFormCheck.makeDirty();
} else {
- if (confirm(
- '"' + s + '" seems already to exist as a translation.\n' +
- 'Insert anyway?'
- )) {
+ if (await confirmAction(t('common.dialog.translation_exists', { text: s }))) {
word_trans.value = oldValue + ' / ' + s;
frame.lwtFormCheck.makeDirty();
}
diff --git a/src/frontend/js/shared/components/confirm_dialog.ts b/src/frontend/js/shared/components/confirm_dialog.ts
index 8b33892c1..05ba1dbb5 100644
--- a/src/frontend/js/shared/components/confirm_dialog.ts
+++ b/src/frontend/js/shared/components/confirm_dialog.ts
@@ -5,6 +5,10 @@
* the theme and shows a browser-chosen title. The native dialog remains only
* as a fallback for when this one cannot be built.
*
+ * The answer comes later, so a handler cannot wait for it and then cancel a
+ * link or a form submission: by then the browser has already acted. A form
+ * handler must use confirmBeforeSubmit(), which cancels first and resubmits.
+ *
* Choosing either button, pressing Escape or clicking outside settles the
* promise. Everything except the confirm button counts as declining, and the
* decline button has the focus, so a stray Enter loses nothing.
@@ -16,9 +20,9 @@
import { t } from '@shared/i18n/translator';
export interface ConfirmDialogOptions {
- /** The question, shown as the dialog's heading. */
- title: string;
- /** What will happen if the user confirms. */
+ /** Heading, for when the message needs one. */
+ title?: string;
+ /** The question, or what will happen if the user confirms. */
message: string;
/** Label of the confirming button. */
confirmLabel: string;
@@ -48,7 +52,6 @@ function buildDialog(options: ConfirmDialogOptions): {
root.className = 'modal is-active confirm-dialog';
root.setAttribute('role', 'alertdialog');
root.setAttribute('aria-modal', 'true');
- root.setAttribute('aria-labelledby', 'confirm-dialog-title');
root.setAttribute('aria-describedby', 'confirm-dialog-message');
const background = document.createElement('div');
@@ -60,10 +63,14 @@ function buildDialog(options: ConfirmDialogOptions): {
const body = document.createElement('section');
body.className = 'modal-card-body';
- const title = document.createElement('p');
- title.id = 'confirm-dialog-title';
- title.className = 'title is-5 mb-2';
- title.textContent = options.title;
+ if (options.title) {
+ const title = document.createElement('p');
+ title.id = 'confirm-dialog-title';
+ title.className = 'title is-5 mb-2';
+ title.textContent = options.title;
+ root.setAttribute('aria-labelledby', title.id);
+ body.append(title);
+ }
const message = document.createElement('p');
message.id = 'confirm-dialog-message';
@@ -82,7 +89,7 @@ function buildDialog(options: ConfirmDialogOptions): {
confirmButton.className = options.danger ? 'button is-danger' : 'button is-primary';
confirmButton.textContent = options.confirmLabel;
- body.append(title, message);
+ body.append(message);
footer.append(cancelButton, confirmButton);
card.append(body, footer);
root.append(background, card);
@@ -105,7 +112,8 @@ export function confirmDialog(options: ConfirmDialogOptions): Promise {
dialog = buildDialog(options);
document.body.append(dialog.root);
} catch {
- return Promise.resolve(window.confirm(`${options.title}\n\n${options.message}`));
+ const text = options.title ? `${options.title}\n\n${options.message}` : options.message;
+ return Promise.resolve(window.confirm(text));
}
const { root, confirmButton, cancelButton, background } = dialog;
@@ -158,3 +166,75 @@ export function confirmDiscardChanges(): Promise {
danger: true
});
}
+
+/**
+ * Ask the user to confirm an action, with the usual buttons.
+ *
+ * @param message The question
+ * @param options Anything to override
+ *
+ * @returns Promise resolving to true if the user confirmed
+ */
+export function confirmAction(
+ message: string,
+ options: Partial = {}
+): Promise {
+ return confirmDialog({
+ message,
+ confirmLabel: t('common.dialog.confirm'),
+ cancelLabel: t('common.cancel'),
+ ...options
+ });
+}
+
+/**
+ * Ask the user to confirm a deletion.
+ *
+ * @param message The question; defaults to a generic one
+ *
+ * @returns Promise resolving to true if the user confirmed
+ */
+export function confirmDeletion(message: string = t('common.confirm_delete')): Promise {
+ return confirmAction(message, { confirmLabel: t('common.delete'), danger: true });
+}
+
+/** Forms whose next submit event is the confirmed one. */
+const confirmedForms = new WeakSet();
+
+/**
+ * Hold a form submission until the user confirms it.
+ *
+ * Call this first thing in a submit handler, and carry on only if it returns
+ * true. The first time it cancels the submission and returns false. If the
+ * user confirms, it submits the form again, with the same submit button, and
+ * this time it returns true.
+ *
+ * @param event The submit event
+ * @param form The form
+ * @param ask Asks the user
+ *
+ * @returns Whether this submission has been confirmed
+ */
+export function confirmBeforeSubmit(
+ event: Event,
+ form: HTMLFormElement,
+ ask: () => Promise
+): boolean {
+ if (confirmedForms.has(form)) {
+ return true;
+ }
+
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ const submitter = (event as SubmitEvent).submitter ?? undefined;
+
+ void ask().then((confirmed) => {
+ if (!confirmed) return;
+ confirmedForms.add(form);
+ form.requestSubmit(submitter);
+ // Submitting runs synchronously. If validation stopped it, the next
+ // attempt must ask again.
+ confirmedForms.delete(form);
+ });
+ return false;
+}
diff --git a/src/frontend/js/shared/forms/bulk_actions.ts b/src/frontend/js/shared/forms/bulk_actions.ts
index f5f7a0533..c2d3409f4 100644
--- a/src/frontend/js/shared/forms/bulk_actions.ts
+++ b/src/frontend/js/shared/forms/bulk_actions.ts
@@ -8,6 +8,8 @@
* @since 2.10.0-fork Extracted from pgm.ts
*/
+import { confirmAction } from '@shared/components/confirm_dialog';
+import { t as translate } from '@shared/i18n/translator';
import { markClick } from '@shared/utils/ui_utilities';
export function selectToggle(toggle: boolean, form: string): void {
@@ -67,13 +69,14 @@ export function multiActionGo(f: HTMLFormElement | FormWithData | undefined, sel
v === 'lower' || v === 'cap'
) {
const checkedCount = document.querySelectorAll('input.markcheck:checked').length;
- const answer = confirm(
- '*** ' + t + ' ***\n\n*** ' + checkedCount +
- ' Record(s) will be affected ***\n\nAre you sure?'
- );
- if (answer) {
- f.submit();
- }
+ void confirmAction(
+ translate('common.dialog.records_affected', { count: checkedCount }),
+ { title: t, danger: v === 'del' || v === 'delsent' }
+ ).then((confirmed) => {
+ if (confirmed) {
+ f.submit();
+ }
+ });
} else {
f.submit();
}
@@ -120,14 +123,16 @@ export function allActionGo(f: HTMLFormElement | FormWithData | undefined, sel:
v === 's5all' || v === 's98all' || v === 's99all' || v === 'todayall' ||
v === 'delsentall' || v === 'capall' || v === 'lowerall'
) {
- const answer = confirm(
- 'THIS IS AN ACTION ON ALL RECORDS\nON ALL PAGES OF THE CURRENT QUERY!\n\n' +
- '*** ' + t + ' ***\n\n*** ' + n + ' Record(s) will be affected ***\n\n' +
- 'ARE YOU SURE?'
- );
- if (answer) {
- f.submit();
- }
+ const warning = translate('common.dialog.all_records_warning');
+ const affected = translate('common.dialog.records_affected', { count: n });
+ void confirmAction(`${warning}\n\n${affected}`, {
+ title: t,
+ danger: true
+ }).then((confirmed) => {
+ if (confirmed) {
+ f.submit();
+ }
+ });
} else {
f.submit();
}
diff --git a/src/frontend/js/shared/offline/offline-indicator.ts b/src/frontend/js/shared/offline/offline-indicator.ts
index e80a6e726..41390171e 100644
--- a/src/frontend/js/shared/offline/offline-indicator.ts
+++ b/src/frontend/js/shared/offline/offline-indicator.ts
@@ -8,6 +8,8 @@
* @since 3.0.0
*/
+import { t } from '@shared/i18n/translator';
+import { confirmDeletion } from '@shared/components/confirm_dialog';
import Alpine from 'alpinejs';
import {
isOfflineStorageAvailable,
@@ -110,7 +112,7 @@ export function offlineIndicator(): OfflineIndicatorData {
async clearAllOfflineData() {
if (this.isClearing) return;
- if (!confirm('Are you sure you want to remove all offline data? This cannot be undone.')) {
+ if (!await confirmDeletion(t('common.dialog.clear_offline'))) {
return;
}
diff --git a/src/frontend/js/shared/utils/simple_interactions.ts b/src/frontend/js/shared/utils/simple_interactions.ts
index 5391a3949..7208ef3b9 100644
--- a/src/frontend/js/shared/utils/simple_interactions.ts
+++ b/src/frontend/js/shared/utils/simple_interactions.ts
@@ -13,6 +13,8 @@
* @since 3.0.0
*/
+import { t } from '@shared/i18n/translator';
+import { confirmAction, confirmBeforeSubmit, confirmDeletion } from '@shared/components/confirm_dialog';
import { lwtFormCheck } from '@shared/forms/unloadformcheck';
import { getCsrfToken } from '@shared/api/client';
import { showAllwordsClick } from './ui_utilities';
@@ -116,12 +118,15 @@ export function cancelAndGoBack(): void {
* Show a confirmation dialog before form submission.
*
* @param message - The confirmation message to display
- * @returns true if user confirmed, false otherwise
+ * @returns Promise resolving to true if the user confirmed
*/
-export function confirmSubmit(message: string = 'Are you sure?'): boolean {
- return confirm(message);
+export function confirmSubmit(message: string = t('common.dialog.are_you_sure')): Promise {
+ return confirmAction(message);
}
+/** Elements whose next click is the confirmed one. */
+const confirmedClicks = new WeakSet();
+
/**
* Initialize simple interaction handlers using data attributes.
*
@@ -145,9 +150,19 @@ export function initSimpleInteractions(): void {
const url = el.dataset.url;
const confirmMsg = el.dataset.confirm;
- // Check for confirmation first
- if (confirmMsg && !confirm(confirmMsg)) {
+ // Check for confirmation first. The answer comes too late to let this
+ // click through, so cancel it and click again once confirmed.
+ if (confirmMsg && !confirmedClicks.has(el)) {
e.preventDefault();
+ e.stopImmediatePropagation();
+ void confirmAction(confirmMsg).then((confirmed) => {
+ if (confirmed) {
+ confirmedClicks.add(el);
+ el.click();
+ // Clicking runs synchronously, and every listener has now seen it.
+ confirmedClicks.delete(el);
+ }
+ });
return;
}
@@ -177,16 +192,13 @@ export function initSimpleInteractions(): void {
break;
case 'confirm-delete':
- // Uses the existing confirmDelete function pattern
- if (!confirm('CONFIRM\n\nAre you sure you want to delete?')) {
- e.preventDefault();
- return;
- }
- // If confirmed and has URL, navigate
- if (url) {
- e.preventDefault();
- navigateTo(url);
- }
+ // Asks first, then navigates if there is a URL
+ e.preventDefault();
+ void confirmDeletion().then((confirmed) => {
+ if (confirmed && url) {
+ navigateTo(url);
+ }
+ });
break;
case 'cancel-form':
@@ -242,21 +254,24 @@ export function initSimpleInteractions(): void {
e.preventDefault();
{
const textId = el.dataset.textId;
- if (textId && confirm('Are you sure?')) {
- el.classList.add('is-loading');
- markAllWellKnown(textId)
- .then(data => {
- data.words.forEach(word => {
- markWordWellKnownInDOM(word.wid, word.hex, word.term);
+ if (textId) {
+ void confirmAction(t('common.dialog.mark_all_known')).then((confirmed) => {
+ if (!confirmed) return;
+ el.classList.add('is-loading');
+ markAllWellKnown(textId)
+ .then(data => {
+ data.words.forEach(word => {
+ markWordWellKnownInDOM(word.wid, word.hex, word.term);
+ });
+ })
+ .catch(err => {
+ console.error('Failed to mark all as well-known:', err);
+ alert('Failed to mark words. Please try again.');
+ })
+ .finally(() => {
+ el.classList.remove('is-loading');
});
- })
- .catch(err => {
- console.error('Failed to mark all as well-known:', err);
- alert('Failed to mark words. Please try again.');
- })
- .finally(() => {
- el.classList.remove('is-loading');
- });
+ });
}
}
break;
@@ -266,21 +281,24 @@ export function initSimpleInteractions(): void {
e.preventDefault();
{
const textId = el.dataset.textId;
- if (textId && confirm('Are you sure?')) {
- el.classList.add('is-loading');
- markAllIgnored(textId)
- .then(data => {
- data.words.forEach(word => {
- markWordIgnoredInDOM(word.wid, word.hex, word.term);
+ if (textId) {
+ void confirmAction(t('common.dialog.mark_all_ignored')).then((confirmed) => {
+ if (!confirmed) return;
+ el.classList.add('is-loading');
+ markAllIgnored(textId)
+ .then(data => {
+ data.words.forEach(word => {
+ markWordIgnoredInDOM(word.wid, word.hex, word.term);
+ });
+ })
+ .catch(err => {
+ console.error('Failed to mark all as ignored:', err);
+ alert('Failed to mark words. Please try again.');
+ })
+ .finally(() => {
+ el.classList.remove('is-loading');
});
- })
- .catch(err => {
- console.error('Failed to mark all as ignored:', err);
- alert('Failed to mark words. Please try again.');
- })
- .finally(() => {
- el.classList.remove('is-loading');
- });
+ });
}
}
break;
@@ -340,18 +358,19 @@ export function initSimpleInteractions(): void {
}
});
- // Handle form submission confirmation and auto-submit
+ // Form submission confirmation. Capturing, so that it comes before the
+ // form's own handlers: they should not run until the user has confirmed.
document.addEventListener('submit', (e) => {
const form = e.target as HTMLFormElement;
-
- // Form submission confirmation
if (form.dataset.confirmSubmit !== undefined) {
- const message = form.dataset.confirmSubmit || 'Are you sure?';
- if (!confirm(message)) {
- e.preventDefault();
- return;
- }
+ const message = form.dataset.confirmSubmit || t('common.dialog.are_you_sure');
+ confirmBeforeSubmit(e, form, () => confirmAction(message, { danger: true }));
}
+ }, true);
+
+ // Handle auto-submit and loading state
+ document.addEventListener('submit', (e) => {
+ const form = e.target as HTMLFormElement;
// Forms that auto-submit by clicking a button
if (form.dataset.autoSubmitButton) {
diff --git a/src/frontend/js/shared/utils/ui_utilities.ts b/src/frontend/js/shared/utils/ui_utilities.ts
index 2c5527db8..0ac921fea 100644
--- a/src/frontend/js/shared/utils/ui_utilities.ts
+++ b/src/frontend/js/shared/utils/ui_utilities.ts
@@ -6,6 +6,7 @@
* @since 1.6.16-fork
*/
+import { confirmDeletion } from '@shared/components/confirm_dialog';
import { check } from '@shared/forms/form_validation';
import { getCsrfToken } from '@shared/api/client';
import { changeImprAnnText, changeImprAnnRadio, showSimilarTerms } from '@modules/vocabulary/services/term_operations';
@@ -42,10 +43,10 @@ export function markClick(): void {
/**
* Show a confirmation dialog for delete operations.
*
- * @returns true if user confirmed deletion, false otherwise
+ * @returns Promise resolving to true if the user confirmed deletion
*/
-export function confirmDelete(): boolean {
- return confirm('CONFIRM\n\nAre you sure you want to delete?');
+export function confirmDelete(): Promise {
+ return confirmDeletion();
}
/**
@@ -59,14 +60,14 @@ async function handleConfirmDelete(event: Event): Promise {
const href = el.getAttribute('href');
const method = el.getAttribute('data-method');
- if (!confirmDelete()) {
- event.preventDefault();
+ // Cancel first: by the time the user answers, the link would be followed.
+ event.preventDefault();
+ if (!await confirmDelete()) {
return;
}
// If data-method="delete", send DELETE request instead of following link
if (method?.toLowerCase() === 'delete' && href) {
- event.preventDefault();
try {
const headers: Record = {};
const csrf = getCsrfToken();
@@ -86,7 +87,9 @@ async function handleConfirmDelete(event: Event): Promise {
alert('Delete failed: ' + (error instanceof Error ? error.message : 'Unknown error'));
}
}
- // Otherwise, let the default link behavior proceed (confirmDelete already returned true)
+ else if (href) {
+ window.location.href = href;
+ }
}
/**
diff --git a/tests/frontend/admin/settings_form.test.ts b/tests/frontend/admin/settings_form.test.ts
index dd948561c..a1e470468 100644
--- a/tests/frontend/admin/settings_form.test.ts
+++ b/tests/frontend/admin/settings_form.test.ts
@@ -1,6 +1,7 @@
/**
* Tests for settings_form.ts - Settings form interactions
*/
+import { answerConfirms, confirmPrompts, settle, trackSubmissions } from '../helpers/confirm_answers';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
settingsFormApp,
@@ -73,20 +74,25 @@ describe('settings_form.ts', () => {
expect(backSpy).toHaveBeenCalled();
});
- it('confirmSubmit returns true and sets isSubmitting when confirmed', () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
-
+ it('confirmSubmit submits and sets isSubmitting once confirmed', async () => {
+ answerConfirms(true);
+ const submissions = trackSubmissions();
+ document.body.innerHTML = '';
+ const form = document.querySelector('form') as HTMLFormElement;
const app = settingsFormApp();
- const event = new Event('submit', { cancelable: true });
- const result = app.confirmSubmit(event, 'Are you sure?');
+ form.addEventListener('submit', (event) => app.confirmSubmit(event, 'Are you sure?'));
+
+ form.requestSubmit();
- expect(result).toBe(true);
+ expect(submissions).toHaveLength(0);
+ await settle();
+ expect(submissions).toHaveLength(1);
expect(app.isSubmitting).toBe(true);
- expect(window.confirm).toHaveBeenCalledWith('Are you sure?');
+ expect(confirmPrompts).toHaveBeenCalledWith('Are you sure?');
});
- it('confirmSubmit returns false and prevents default when cancelled', () => {
- vi.spyOn(window, 'confirm').mockReturnValue(false);
+ it('confirmSubmit returns false and prevents default when cancelled', async () => {
+ answerConfirms(false);
const app = settingsFormApp();
const event = new Event('submit', { cancelable: true });
@@ -94,19 +100,25 @@ describe('settings_form.ts', () => {
const result = app.confirmSubmit(event, 'Continue?');
+ await settle();
expect(result).toBe(false);
expect(preventDefaultSpy).toHaveBeenCalled();
expect(app.isSubmitting).toBe(false);
});
- it('confirmSubmit uses default message when not provided', () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ it('confirmSubmit uses default message when not provided', async () => {
+ answerConfirms(true);
+ trackSubmissions();
+ document.body.innerHTML = '';
+ const form = document.querySelector('form') as HTMLFormElement;
const app = settingsFormApp();
- const event = new Event('submit', { cancelable: true });
- app.confirmSubmit(event);
+ form.addEventListener('submit', (event) => app.confirmSubmit(event));
+
+ form.requestSubmit();
- expect(window.confirm).toHaveBeenCalledWith('Are you sure?');
+ await settle();
+ expect(confirmPrompts).toHaveBeenCalledWith('Are you sure?');
});
});
@@ -174,48 +186,50 @@ describe('settings_form.ts', () => {
// ===========================================================================
describe('initConfirmSubmitForms', () => {
- it('shows confirmation dialog on form submit', () => {
+ it('shows confirmation dialog on form submit', async () => {
document.body.innerHTML = `
`;
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
+ const confirmSpy = answerConfirms(true);
initConfirmSubmitForms();
const form = document.querySelector('form') as HTMLFormElement;
form.dispatchEvent(new Event('submit', { bubbles: true }));
+ await settle();
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to proceed?');
});
- it('uses default message when data-confirm-message is not set', () => {
+ it('uses default message when data-confirm-message is not set', async () => {
document.body.innerHTML = `
`;
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
+ const confirmSpy = answerConfirms(true);
initConfirmSubmitForms();
const form = document.querySelector('form') as HTMLFormElement;
form.dispatchEvent(new Event('submit', { bubbles: true }));
+ await settle();
expect(confirmSpy).toHaveBeenCalledWith('Are you sure?');
});
- it('prevents form submission when user cancels', () => {
+ it('prevents form submission when user cancels', async () => {
document.body.innerHTML = `
`;
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
+ const confirmSpy = answerConfirms(false);
initConfirmSubmitForms();
@@ -225,79 +239,85 @@ describe('settings_form.ts', () => {
form.dispatchEvent(submitEvent);
+ await settle();
expect(confirmSpy).toHaveBeenCalled();
expect(preventDefaultSpy).toHaveBeenCalled();
});
- it('allows form submission when user confirms', () => {
+ it('submits once when user confirms', async () => {
document.body.innerHTML = `
`;
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
+ const confirmSpy = answerConfirms(true);
+ const submissions = trackSubmissions();
initConfirmSubmitForms();
const form = document.querySelector('form') as HTMLFormElement;
- const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
- const preventDefaultSpy = vi.spyOn(submitEvent, 'preventDefault');
-
- form.dispatchEvent(submitEvent);
+ form.requestSubmit();
+ expect(submissions).toHaveLength(0);
+ await settle();
expect(confirmSpy).toHaveBeenCalled();
- expect(preventDefaultSpy).not.toHaveBeenCalled();
+ expect(submissions).toHaveLength(1);
});
- it('ignores non-confirm-submit forms', () => {
+ it('ignores non-confirm-submit forms', async () => {
document.body.innerHTML = `
`;
- const confirmSpy = vi.spyOn(window, 'confirm');
+ const confirmSpy = answerConfirms(true);
initConfirmSubmitForms();
const form = document.querySelector('form') as HTMLFormElement;
form.dispatchEvent(new Event('submit', { bubbles: true }));
+ await settle();
expect(confirmSpy).not.toHaveBeenCalled();
});
- it('adds loading state to submit button after confirmation', () => {
+ it('adds loading state to submit button after confirmation', async () => {
document.body.innerHTML = `
`;
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ answerConfirms(true);
initConfirmSubmitForms();
const form = document.querySelector('form') as HTMLFormElement;
const submitButton = form.querySelector('input[type="submit"]') as HTMLInputElement;
+ await settle();
expect(submitButton.classList.contains('is-loading')).toBe(false);
expect(submitButton.disabled).toBe(false);
- form.dispatchEvent(new Event('submit', { bubbles: true }));
+ trackSubmissions();
+ form.requestSubmit();
+ expect(submitButton.classList.contains('is-loading')).toBe(false);
+ await settle();
expect(submitButton.classList.contains('is-loading')).toBe(true);
expect(submitButton.disabled).toBe(true);
});
- it('does not add loading state when user cancels', () => {
+ it('does not add loading state when user cancels', async () => {
document.body.innerHTML = `
`;
- vi.spyOn(window, 'confirm').mockReturnValue(false);
+ answerConfirms(false);
initConfirmSubmitForms();
@@ -306,6 +326,7 @@ describe('settings_form.ts', () => {
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
+ await settle();
expect(submitButton.classList.contains('is-loading')).toBe(false);
expect(submitButton.disabled).toBe(false);
});
diff --git a/tests/frontend/admin/table_management.test.ts b/tests/frontend/admin/table_management.test.ts
index 0ae3c1d47..aede4f2a9 100644
--- a/tests/frontend/admin/table_management.test.ts
+++ b/tests/frontend/admin/table_management.test.ts
@@ -1,6 +1,7 @@
/**
* Tests for table_management.ts - Table set management Alpine component
*/
+import { answerConfirms, settle, trackSubmissions } from '../helpers/confirm_answers';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
tableManagementApp,
@@ -128,21 +129,26 @@ describe('table_management.ts', () => {
expect(event.defaultPrevented).toBe(true);
});
- it('shows confirmation dialog when deleting', () => {
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
+ it('deletes only after the user confirms', async () => {
+ const confirmSpy = answerConfirms(true);
+ const submissions = trackSubmissions();
+ document.body.innerHTML = '';
+ const form = document.querySelector('form') as HTMLFormElement;
const app = tableManagementApp();
app.deletePrefix = 'test_prefix';
app.confirmDelete = true;
+ form.addEventListener('submit', (event) => app.submitDelete(event));
- const event = new Event('submit', { cancelable: true });
- app.submitDelete(event);
+ form.requestSubmit();
+ expect(submissions).toHaveLength(0);
+ await settle();
expect(confirmSpy).toHaveBeenCalled();
- expect(event.defaultPrevented).toBe(false);
+ expect(submissions).toHaveLength(1);
});
- it('prevents submission when confirmation is cancelled', () => {
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
+ it('prevents submission when confirmation is cancelled', async () => {
+ const confirmSpy = answerConfirms(false);
const app = tableManagementApp();
app.deletePrefix = 'test_prefix';
app.confirmDelete = true;
@@ -150,6 +156,7 @@ describe('table_management.ts', () => {
const event = new Event('submit', { cancelable: true });
app.submitDelete(event);
+ await settle();
expect(confirmSpy).toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
});
diff --git a/tests/frontend/books/book_list.test.ts b/tests/frontend/books/book_list.test.ts
index b2042b9aa..09ca877e2 100644
--- a/tests/frontend/books/book_list.test.ts
+++ b/tests/frontend/books/book_list.test.ts
@@ -5,6 +5,7 @@
* pagination bounds, the page-collapse after deleting a page's last row, and
* the null-author case that would otherwise print the string "null".
*/
+import { answerConfirms, settle } from '../helpers/confirm_answers';
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('alpinejs', () => ({
@@ -153,7 +154,7 @@ describe('book_list.ts', () => {
describe('deleting', () => {
it('steps back a page when the last row on it is removed', async () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ answerConfirms(true);
removeMock.mockResolvedValue({ data: { success: true, message: 'gone' } });
listMock.mockResolvedValue(pageOf([aBook(9)], 2, 2));
@@ -163,21 +164,23 @@ describe('book_list.ts', () => {
await component.confirmDelete(aBook(9));
+ await settle();
expect(component.page).toBe(1);
expect(component.notification).toBe('gone');
});
it('does nothing when the confirmation is declined', async () => {
- vi.spyOn(window, 'confirm').mockReturnValue(false);
+ answerConfirms(false);
const component = bookListData();
await component.confirmDelete(aBook(1));
+ await settle();
expect(removeMock).not.toHaveBeenCalled();
});
it('reports a failed delete rather than dropping the row silently', async () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ answerConfirms(true);
removeMock.mockResolvedValue({ data: { success: false, error: 'in use' } });
listMock.mockResolvedValue(pageOf([aBook(1), aBook(2)]));
@@ -185,6 +188,7 @@ describe('book_list.ts', () => {
await component.load();
await component.confirmDelete(aBook(1));
+ await settle();
expect(component.error).toBe('in use');
});
});
diff --git a/tests/frontend/core/language_settings.test.ts b/tests/frontend/core/language_settings.test.ts
index 3670bc095..f33a57893 100644
--- a/tests/frontend/core/language_settings.test.ts
+++ b/tests/frontend/core/language_settings.test.ts
@@ -1,6 +1,7 @@
/**
* Tests for core/language_settings.ts - Language settings utilities
*/
+import { answerConfirms, settle } from '../helpers/confirm_answers';
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
// Mock dependencies
@@ -248,51 +249,57 @@ describe('core/language_settings.ts', () => {
});
it('shows confirmation dialog', async () => {
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
+ const confirmSpy = answerConfirms(true);
await iknowall(1);
- expect(confirmSpy).toHaveBeenCalledWith('Are you sure?');
+ await settle();
+ expect(confirmSpy).toHaveBeenCalledWith('Mark all unknown words as Well Known?');
});
it('calls API when confirmed', async () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ answerConfirms(true);
await iknowall(1);
+ await settle();
expect(apiPut).toHaveBeenCalledWith('/texts/1/mark-all-wellknown', {});
});
it('does not call API when cancelled', async () => {
- vi.spyOn(window, 'confirm').mockReturnValue(false);
+ answerConfirms(false);
vi.mocked(apiPut).mockClear();
await iknowall(1);
+ await settle();
expect(apiPut).not.toHaveBeenCalled();
});
it('handles string text ID', async () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ answerConfirms(true);
await iknowall('42');
+ await settle();
expect(apiPut).toHaveBeenCalledWith('/texts/42/mark-all-wellknown', {});
});
it('handles numeric text ID', async () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ answerConfirms(true);
await iknowall(123);
+ await settle();
expect(apiPut).toHaveBeenCalledWith('/texts/123/mark-all-wellknown', {});
});
it('reloads page after successful API call', async () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ answerConfirms(true);
await iknowall(1);
+ await settle();
expect(reloadSpy).toHaveBeenCalled();
});
});
@@ -512,11 +519,12 @@ describe('core/language_settings.ts', () => {
});
it('iknowall handles zero text ID', async () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ answerConfirms(true);
vi.spyOn(console, 'error').mockImplementation(() => {}); // Suppress expected error
await iknowall(0);
+ await settle();
expect(apiPut).toHaveBeenCalledWith('/texts/0/mark-all-wellknown', {});
});
diff --git a/tests/frontend/core/simple_interactions.test.ts b/tests/frontend/core/simple_interactions.test.ts
index 9449a488c..ec016982d 100644
--- a/tests/frontend/core/simple_interactions.test.ts
+++ b/tests/frontend/core/simple_interactions.test.ts
@@ -1,6 +1,7 @@
/**
* Tests for simple_interactions.ts - Navigation and confirmation utilities.
*/
+import { answerConfirms, confirmPrompts, settle, trackSubmissions } from '../helpers/confirm_answers';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// Mock the dependencies
@@ -153,29 +154,32 @@ describe('simple_interactions.ts', () => {
});
describe('confirmSubmit', () => {
- it('shows confirmation dialog with default message', () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ it('shows confirmation dialog with default message', async () => {
+ answerConfirms(true);
- const result = confirmSubmit();
+ const result = await confirmSubmit();
- expect(window.confirm).toHaveBeenCalledWith('Are you sure?');
+ await settle();
+ expect(confirmPrompts).toHaveBeenCalledWith('Are you sure?');
expect(result).toBe(true);
});
- it('shows confirmation dialog with custom message', () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ it('shows confirmation dialog with custom message', async () => {
+ answerConfirms(true);
- const result = confirmSubmit('Delete this item?');
+ const result = await confirmSubmit('Delete this item?');
- expect(window.confirm).toHaveBeenCalledWith('Delete this item?');
+ await settle();
+ expect(confirmPrompts).toHaveBeenCalledWith('Delete this item?');
expect(result).toBe(true);
});
- it('returns false when user cancels', () => {
- vi.spyOn(window, 'confirm').mockReturnValue(false);
+ it('returns false when user cancels', async () => {
+ answerConfirms(false);
- const result = confirmSubmit('Test message');
+ const result = await confirmSubmit('Test message');
+ await settle();
expect(result).toBe(false);
});
});
@@ -263,8 +267,8 @@ describe('simple_interactions.ts', () => {
});
describe('data-action="confirm-delete"', () => {
- it('shows confirmation and navigates if confirmed', () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ it('shows confirmation and navigates if confirmed', async () => {
+ answerConfirms(true);
document.body.innerHTML = `
`;
@@ -272,12 +276,13 @@ describe('simple_interactions.ts', () => {
const button = document.querySelector('[data-action="confirm-delete"]') as HTMLElement;
button.dispatchEvent(new Event('click', { bubbles: true }));
- expect(window.confirm).toHaveBeenCalledWith('CONFIRM\n\nAre you sure you want to delete?');
+ await settle();
+ expect(confirmPrompts).toHaveBeenCalledWith('Are you sure you want to delete this?');
expect(window.location.href).toBe('/delete/item');
});
- it('does nothing if user cancels', () => {
- vi.spyOn(window, 'confirm').mockReturnValue(false);
+ it('does nothing if user cancels', async () => {
+ answerConfirms(false);
document.body.innerHTML = `
`;
@@ -286,7 +291,8 @@ describe('simple_interactions.ts', () => {
const button = document.querySelector('[data-action="confirm-delete"]') as HTMLElement;
button.dispatchEvent(new Event('click', { bubbles: true }));
- expect(window.confirm).toHaveBeenCalled();
+ await settle();
+ expect(confirmPrompts).toHaveBeenCalled();
expect(window.location.href).toBe('http://localhost/original');
});
});
@@ -347,8 +353,8 @@ describe('simple_interactions.ts', () => {
});
describe('data-confirm attribute', () => {
- it('shows confirmation before executing action', () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ it('shows confirmation before executing action', async () => {
+ answerConfirms(true);
document.body.innerHTML = `
`;
@@ -356,12 +362,13 @@ describe('simple_interactions.ts', () => {
const button = document.querySelector('[data-action="navigate"]') as HTMLElement;
button.dispatchEvent(new Event('click', { bubbles: true }));
- expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to navigate?');
+ await settle();
+ expect(confirmPrompts).toHaveBeenCalledWith('Are you sure you want to navigate?');
expect(window.location.href).toBe('/page');
});
- it('prevents action if confirmation is cancelled', () => {
- vi.spyOn(window, 'confirm').mockReturnValue(false);
+ it('prevents action if confirmation is cancelled', async () => {
+ answerConfirms(false);
document.body.innerHTML = `
`;
@@ -370,30 +377,50 @@ describe('simple_interactions.ts', () => {
const button = document.querySelector('[data-action="navigate"]') as HTMLElement;
button.dispatchEvent(new Event('click', { bubbles: true }));
- expect(window.confirm).toHaveBeenCalled();
+ await settle();
+ expect(confirmPrompts).toHaveBeenCalled();
expect(window.location.href).toBe('http://localhost/original');
});
});
describe('form submission confirmation', () => {
- it('shows confirmation before form submission', () => {
- vi.spyOn(window, 'confirm').mockReturnValue(true);
+ it('shows confirmation before form submission', async () => {
+ answerConfirms(true);
document.body.innerHTML = `
`;
+ const submissions = trackSubmissions();
+ document.querySelector('form')!.requestSubmit();
+
+ expect(submissions).toHaveLength(0);
+ await settle();
+ expect(confirmPrompts).toHaveBeenCalledWith('Submit this form?');
+ expect(submissions).toHaveLength(1);
+ });
+
+ it('asks before the form\'s own handlers run', async () => {
+ answerConfirms(false);
+ document.body.innerHTML = `
+
+ `;
const form = document.querySelector('form')!;
- const event = new Event('submit', { bubbles: true, cancelable: true });
- form.dispatchEvent(event);
+ const ownHandler = vi.fn();
+ form.addEventListener('submit', ownHandler);
+
+ form.requestSubmit();
+ await settle();
- expect(window.confirm).toHaveBeenCalledWith('Submit this form?');
- expect(event.defaultPrevented).toBe(false);
+ expect(confirmPrompts).toHaveBeenCalledWith('Restore?');
+ expect(ownHandler).not.toHaveBeenCalled();
});
- it('prevents submission if confirmation is cancelled', () => {
- vi.spyOn(window, 'confirm').mockReturnValue(false);
+ it('prevents submission if confirmation is cancelled', async () => {
+ answerConfirms(false);
document.body.innerHTML = `