From eed20214d319fcdbf084a339accf29e3e8394411 Mon Sep 17 00:00:00 2001 From: HugoFara Date: Wed, 4 Nov 2026 14:47:44 +0100 Subject: [PATCH] refactor(ui): replace every native confirmation with the in-app dialog The remaining 35 confirm() calls move to confirmAction() and confirmDeletion(), and prompts that were English in every locale are translated. The answer now arrives later, which changes three things: - A handler can no longer wait and then cancel a form submission, so confirmBeforeSubmit() cancels first and resubmits with the same button once confirmed. Clicks with data-confirm replay the same way. - data-confirm-submit asks in the capture phase, before the form's own handlers. The backup page set `restoring` before the question, so cancelling a restore left the page stuck. - `!confirmDelete()` on a promise is always false and tsc does not flag it, which would have deleted without asking. A test now rejects that shape and any new native confirm(). The tests answer the real dialog rather than a stub. --- CHANGELOG.md | 8 +- cypress/e2e/13-books.cy.ts | 2 +- cypress/e2e/14-dictionaries.cy.ts | 8 +- locale/ar/common.json | 16 +- locale/de/common.json | 16 +- locale/en/common.json | 16 +- locale/es/common.json | 16 +- locale/fa/common.json | 16 +- locale/fr/common.json | 16 +- locale/it/common.json | 16 +- locale/ja/common.json | 16 +- locale/pt/common.json | 16 +- locale/ru/common.json | 16 +- locale/zh/common.json | 16 +- src/Modules/Text/Views/print_alpine.php | 2 +- src/frontend/css/base/styles.css | 4 + .../js/modules/admin/pages/settings_form.ts | 13 +- .../modules/admin/pages/table_management.ts | 13 +- .../js/modules/admin/pages/user_management.ts | 5 +- .../js/modules/book/pages/book_detail.ts | 3 +- .../js/modules/book/pages/book_list.ts | 3 +- .../dictionary/pages/dictionary_list.ts | 3 +- .../js/modules/feed/pages/feed_manager_app.ts | 10 +- .../language/stores/language_settings.ts | 5 +- .../js/modules/tags/pages/tag_list_app.ts | 7 +- .../js/modules/text/components/text_reader.ts | 6 +- .../text/pages/archived_texts_grouped_app.ts | 18 +-- .../js/modules/text/pages/text_list.ts | 4 +- .../js/modules/text/pages/text_print_app.ts | 13 +- .../modules/text/pages/texts_grouped_app.ts | 14 +- .../vocabulary/components/word_modal.ts | 4 +- .../vocabulary/components/word_popover.ts | 4 +- .../modules/vocabulary/pages/word_list_app.ts | 5 +- .../vocabulary/services/translation_api.ts | 9 +- .../js/shared/components/confirm_dialog.ts | 100 ++++++++++-- src/frontend/js/shared/forms/bulk_actions.ts | 35 ++-- .../js/shared/offline/offline-indicator.ts | 4 +- .../js/shared/utils/simple_interactions.ts | 121 ++++++++------ src/frontend/js/shared/utils/ui_utilities.ts | 17 +- tests/frontend/admin/settings_form.test.ts | 89 ++++++---- tests/frontend/admin/table_management.test.ts | 21 ++- tests/frontend/books/book_list.test.ts | 10 +- tests/frontend/core/language_settings.test.ts | 24 ++- .../frontend/core/simple_interactions.test.ts | 114 ++++++++----- tests/frontend/core/ui_utilities.test.ts | 24 +-- tests/frontend/forms/bulk_actions.test.ts | 35 ++-- tests/frontend/helpers/confirm_answers.ts | 75 +++++++++ .../offline/offline_indicator.test.ts | 11 +- tests/frontend/terms/translation_api.test.ts | 11 +- tests/frontend/texts/text_list.test.ts | 6 +- tests/frontend/texts/text_print.test.ts | 6 +- tests/frontend/texts/text_reader.test.ts | 37 +++-- .../frontend/texts/texts_grouped_app.test.ts | 18 +-- tests/frontend/ui/confirm_dialog.test.ts | 153 +++++++++++++++++- tests/frontend/vocabulary/word_modal.test.ts | 18 ++- .../frontend/vocabulary/word_popover.test.ts | 13 +- tests/frontend/words/word_list_app.test.ts | 7 +- 57 files changed, 957 insertions(+), 331 deletions(-) create mode 100644 tests/frontend/helpers/confirm_answers.ts 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 = `
@@ -404,12 +431,13 @@ describe('simple_interactions.ts', () => { const event = new Event('submit', { bubbles: true, cancelable: true }); form.dispatchEvent(event); - expect(window.confirm).toHaveBeenCalled(); + await settle(); + expect(confirmPrompts).toHaveBeenCalled(); expect(event.defaultPrevented).toBe(true); }); - it('uses default message if not specified', () => { - vi.spyOn(window, 'confirm').mockReturnValue(true); + it('uses default message if not specified', async () => { + answerConfirms(true); document.body.innerHTML = ` @@ -420,7 +448,8 @@ describe('simple_interactions.ts', () => { const event = new Event('submit', { bubbles: true, cancelable: true }); form.dispatchEvent(event); - expect(window.confirm).toHaveBeenCalledWith('Are you sure?'); + await settle(); + expect(confirmPrompts).toHaveBeenCalledWith('Are you sure?'); }); }); @@ -501,7 +530,7 @@ describe('simple_interactions.ts', () => { describe('data-action="know-all"', () => { it('calls API to mark all well-known and updates DOM', async () => { - vi.spyOn(window, 'confirm').mockReturnValue(true); + answerConfirms(true); const mockWords = [ { wid: 1, hex: 'abc', term: 'word1', status: 99 }, { wid: 2, hex: 'def', term: 'word2', status: 99 } @@ -518,7 +547,8 @@ describe('simple_interactions.ts', () => { const button = document.querySelector('[data-action="know-all"]') as HTMLElement; button.dispatchEvent(new Event('click', { bubbles: true })); - expect(window.confirm).toHaveBeenCalledWith('Are you sure?'); + await settle(); + expect(confirmPrompts).toHaveBeenCalledWith('Mark all unknown words as Well Known?'); expect(fetchSpy).toHaveBeenCalledWith('/api/v1/texts/42/mark-all-wellknown', { method: 'PUT', headers: { 'Content-Type': 'application/json' } @@ -533,8 +563,8 @@ describe('simple_interactions.ts', () => { fetchSpy.mockRestore(); }); - it('does nothing if cancelled', () => { - vi.spyOn(window, 'confirm').mockReturnValue(false); + it('does nothing if cancelled', async () => { + answerConfirms(false); const fetchSpy = vi.spyOn(globalThis, 'fetch'); document.body.innerHTML = ` @@ -543,6 +573,7 @@ describe('simple_interactions.ts', () => { const button = document.querySelector('[data-action="know-all"]') as HTMLElement; button.dispatchEvent(new Event('click', { bubbles: true })); + await settle(); expect(fetchSpy).not.toHaveBeenCalled(); fetchSpy.mockRestore(); }); @@ -550,7 +581,7 @@ describe('simple_interactions.ts', () => { describe('data-action="ignore-all"', () => { it('calls API to mark all ignored and updates DOM', async () => { - vi.spyOn(window, 'confirm').mockReturnValue(true); + answerConfirms(true); const mockWords = [ { wid: 3, hex: 'ghi', term: 'word3', status: 98 } ]; @@ -566,7 +597,8 @@ describe('simple_interactions.ts', () => { const button = document.querySelector('[data-action="ignore-all"]') as HTMLElement; button.dispatchEvent(new Event('click', { bubbles: true })); - expect(window.confirm).toHaveBeenCalledWith('Are you sure?'); + await settle(); + expect(confirmPrompts).toHaveBeenCalledWith('Mark all unknown words as Ignored?'); expect(fetchSpy).toHaveBeenCalledWith('/api/v1/texts/42/mark-all-ignored', { method: 'PUT', headers: { 'Content-Type': 'application/json' } diff --git a/tests/frontend/core/ui_utilities.test.ts b/tests/frontend/core/ui_utilities.test.ts index ddfd85f4d..c80946e44 100644 --- a/tests/frontend/core/ui_utilities.test.ts +++ b/tests/frontend/core/ui_utilities.test.ts @@ -1,6 +1,7 @@ /** * Tests for ui_utilities.ts - DOM manipulation, tooltips, and form wrapping */ +import { answerConfirms, confirmPrompts, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Now import the module @@ -73,20 +74,22 @@ describe('ui_utilities.ts', () => { // =========================================================================== describe('confirmDelete', () => { - it('returns true when user confirms', () => { - vi.spyOn(window, 'confirm').mockReturnValue(true); + it('returns true when user confirms', async () => { + answerConfirms(true); - const result = confirmDelete(); + const result = await confirmDelete(); + await settle(); expect(result).toBe(true); - expect(window.confirm).toHaveBeenCalledWith('CONFIRM\n\nAre you sure you want to delete?'); + expect(confirmPrompts).toHaveBeenCalledWith('Are you sure you want to delete this?'); }); - it('returns false when user cancels', () => { - vi.spyOn(window, 'confirm').mockReturnValue(false); + it('returns false when user cancels', async () => { + answerConfirms(false); - const result = confirmDelete(); + const result = await confirmDelete(); + await settle(); expect(result).toBe(false); }); }); @@ -599,8 +602,8 @@ describe('ui_utilities.ts', () => { expect(radioInput).not.toBeNull(); }); - it('handles confirm delete elements', () => { - vi.spyOn(window, 'confirm').mockReturnValue(false); + it('handles confirm delete elements', async () => { + answerConfirms(false); document.body.innerHTML = 'Delete'; prepareMainAreas(); @@ -608,7 +611,8 @@ describe('ui_utilities.ts', () => { const deleteLink = document.querySelector('.confirmdelete') as HTMLElement; deleteLink.click(); - expect(window.confirm).toHaveBeenCalled(); + await settle(); + expect(confirmPrompts).toHaveBeenCalled(); }); }); diff --git a/tests/frontend/forms/bulk_actions.test.ts b/tests/frontend/forms/bulk_actions.test.ts index 8bfe79402..5c3505b6c 100644 --- a/tests/frontend/forms/bulk_actions.test.ts +++ b/tests/frontend/forms/bulk_actions.test.ts @@ -1,6 +1,7 @@ /** * Tests for bulk_actions.ts - Bulk action utilities for LWT */ +import { answerConfirms, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { selectToggle, @@ -280,7 +281,7 @@ describe('bulk_actions.ts', () => { expect(alertSpy).toHaveBeenCalledWith('Please no tags longer than 20 char.!'); }); - it('confirms for del action', () => { + it('confirms for del action', async () => { document.body.innerHTML = `
@@ -313,16 +315,17 @@ describe('bulk_actions.ts', () => { const form = document.querySelector('form') as HTMLFormElement; const select = document.querySelector('select') as HTMLSelectElement; const submitSpy = vi.spyOn(form, 'submit').mockImplementation(() => {}); - vi.spyOn(window, 'confirm').mockReturnValue(false); + answerConfirms(false); multiActionGo(form, select); + await settle(); expect(submitSpy).not.toHaveBeenCalled(); }); it.each(['smi1', 'spl1', 's1', 's5', 's98', 's99', 'today', 'delsent', 'lower', 'cap'])( 'confirms for %s action', - (action) => { + async (action) => { document.body.innerHTML = `
@@ -460,19 +464,20 @@ describe('bulk_actions.ts', () => { const form = document.querySelector('form') as HTMLFormElement; const select = document.querySelector('select') as HTMLSelectElement; const submitSpy = vi.spyOn(form, 'submit').mockImplementation(() => {}); - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + const confirmSpy = answerConfirms(true); allActionGo(form, select, 25); + await settle(); expect(confirmSpy).toHaveBeenCalledWith( - expect.stringContaining('25 Record(s)') + expect.stringContaining('Records affected: 25') ); expect(submitSpy).toHaveBeenCalled(); }); it.each(['smi1all', 'spl1all', 's1all', 's5all', 's98all', 's99all', 'todayall', 'delsentall', 'capall', 'lowerall'])( 'confirms for %s action', - (action) => { + async (action) => { document.body.innerHTML = `
@@ -503,12 +509,13 @@ describe('bulk_actions.ts', () => { const form = document.querySelector('form') as HTMLFormElement; const select = document.querySelector('select') as HTMLSelectElement; vi.spyOn(form, 'submit').mockImplementation(() => {}); - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + const confirmSpy = answerConfirms(true); allActionGo(form, select, 10); + await settle(); expect(confirmSpy).toHaveBeenCalledWith( - expect.stringContaining('ALL PAGES') + expect.stringContaining('all pages') ); }); diff --git a/tests/frontend/helpers/confirm_answers.ts b/tests/frontend/helpers/confirm_answers.ts new file mode 100644 index 000000000..b653096e6 --- /dev/null +++ b/tests/frontend/helpers/confirm_answers.ts @@ -0,0 +1,75 @@ +/** + * Answer the real in-app confirmation dialog from a test. + * + * The dialog answers later, so a test must wait for it: call answerConfirms() + * before the action, and `await settle()` after it. + */ +import { afterEach, vi } from 'vitest'; + +/** Records each dialog's text, for asserting what was asked. */ +export const confirmPrompts = vi.fn(); + +let observer: MutationObserver | null = null; + +afterEach(() => { + observer?.disconnect(); + observer = null; +}); + +/** + * Answer every confirmation dialog that appears from now on. + * + * @param answer true for the confirming button, false for the other one + * + * @returns The recorder of what was asked + */ +export function answerConfirms(answer: boolean): typeof confirmPrompts { + observer?.disconnect(); + confirmPrompts.mockClear(); + + observer = new MutationObserver(() => { + const dialog = document.querySelector('.confirm-dialog'); + if (!dialog) return; + confirmPrompts(dialog.querySelector('#confirm-dialog-message')?.textContent); + const buttons = dialog.querySelectorAll('.confirm-dialog__actions button'); + buttons[answer ? 1 : 0].click(); + }); + observer.observe(document.body, { childList: true }); + + return confirmPrompts; +} + +/** + * Wait for dialogs to be answered and for what follows them to run. + */ +export async function settle(): Promise { + for (let i = 0; i < 5; i++) { + await (vi.isFakeTimers() + ? Promise.resolve() + : new Promise((resolve) => setTimeout(resolve, 0))); + } +} + +let submissionListener: ((event: Event) => void) | null = null; + +/** + * Count the form submissions that actually go ahead. + * + * Listens last, on the window, and stops the navigation jsdom cannot perform. + * + * @returns The submit events that nothing cancelled + */ +export function trackSubmissions(): Event[] { + if (submissionListener) { + window.removeEventListener('submit', submissionListener); + } + const submissions: Event[] = []; + submissionListener = (event: Event) => { + if (!event.defaultPrevented) { + submissions.push(event); + event.preventDefault(); + } + }; + window.addEventListener('submit', submissionListener); + return submissions; +} diff --git a/tests/frontend/offline/offline_indicator.test.ts b/tests/frontend/offline/offline_indicator.test.ts index 263f38df1..a4dbc3952 100644 --- a/tests/frontend/offline/offline_indicator.test.ts +++ b/tests/frontend/offline/offline_indicator.test.ts @@ -1,6 +1,7 @@ /** * Tests for shared/offline/offline-indicator.ts - Alpine.js offline status indicator component */ +import { answerConfirms, confirmPrompts, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Mock dependencies before importing @@ -41,7 +42,7 @@ describe('shared/offline/offline-indicator.ts', () => { let addEventListenerSpy: ReturnType; let removeEventListenerSpy: ReturnType; let consoleErrorSpy: ReturnType; - let confirmSpy: ReturnType; + const confirmSpy = confirmPrompts; beforeEach(() => { vi.clearAllMocks(); @@ -49,7 +50,7 @@ describe('shared/offline/offline-indicator.ts', () => { addEventListenerSpy = vi.spyOn(window, 'addEventListener'); removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + answerConfirms(true); vi.mocked(isOfflineStorageAvailable).mockReturnValue(true); vi.mocked(getOfflineSummary).mockResolvedValue({ @@ -71,7 +72,6 @@ describe('shared/offline/offline-indicator.ts', () => { addEventListenerSpy.mockRestore(); removeEventListenerSpy.mockRestore(); consoleErrorSpy.mockRestore(); - confirmSpy.mockRestore(); }); // =========================================================================== @@ -273,7 +273,7 @@ describe('shared/offline/offline-indicator.ts', () => { }); it('clears data when confirmed', async () => { - confirmSpy.mockReturnValue(true); + answerConfirms(true); const component = offlineIndicator(); await component.clearAllOfflineData(); @@ -282,7 +282,7 @@ describe('shared/offline/offline-indicator.ts', () => { }); it('does not clear data when cancelled', async () => { - confirmSpy.mockReturnValue(false); + answerConfirms(false); const component = offlineIndicator(); await component.clearAllOfflineData(); @@ -301,6 +301,7 @@ describe('shared/offline/offline-indicator.ts', () => { const component = offlineIndicator(); const clearPromise = component.clearAllOfflineData(); + await settle(); expect(component.isClearing).toBe(true); resolveClear!(); diff --git a/tests/frontend/terms/translation_api.test.ts b/tests/frontend/terms/translation_api.test.ts index 83b728b14..5ec5ac0e2 100644 --- a/tests/frontend/terms/translation_api.test.ts +++ b/tests/frontend/terms/translation_api.test.ts @@ -1,6 +1,7 @@ /** * Tests for translation_api.ts - Translation API functions */ +import { answerConfirms, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { deleteTranslation, @@ -205,7 +206,7 @@ describe('translation_api.ts', () => { expect(mockInput.value).toBe('existing / additional'); }); - it('prompts for confirmation when translation already exists', () => { + it('prompts for confirmation when translation already exists', async () => { const mockInput = { value: 'duplicate', name: 'WoTranslation' }; const mockForm = { WoTranslation: mockInput }; const makeDirtySpy = vi.fn(); @@ -221,14 +222,15 @@ describe('translation_api.ts', () => { }); // User confirms - vi.spyOn(window, 'confirm').mockReturnValue(true); + answerConfirms(true); addTranslation('duplicate'); + await settle(); expect(mockInput.value).toBe('duplicate / duplicate'); }); - it('does not add when user declines duplicate', () => { + it('does not add when user declines duplicate', async () => { const mockInput = { value: 'duplicate', name: 'WoTranslation' }; const mockForm = { WoTranslation: mockInput }; const makeDirtySpy = vi.fn(); @@ -244,11 +246,12 @@ describe('translation_api.ts', () => { }); // User declines - vi.spyOn(window, 'confirm').mockReturnValue(false); + answerConfirms(false); addTranslation('duplicate'); // Value should remain unchanged + await settle(); expect(mockInput.value).toBe('duplicate'); expect(makeDirtySpy).not.toHaveBeenCalled(); }); diff --git a/tests/frontend/texts/text_list.test.ts b/tests/frontend/texts/text_list.test.ts index 9eb4cd69a..83338c053 100644 --- a/tests/frontend/texts/text_list.test.ts +++ b/tests/frontend/texts/text_list.test.ts @@ -1,6 +1,7 @@ /** * Tests for text_list.ts - Text list page interactions */ +import { settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { initTextList } from '../../../src/frontend/js/modules/text/pages/text_list'; @@ -367,20 +368,21 @@ describe('text_list.ts', () => { // =========================================================================== describe('Delete Confirmation', () => { - it('navigates to delete URL when confirmed', () => { + it('navigates to delete URL when confirmed', async () => { document.body.innerHTML = `
Delete
`; - (confirmDelete as any).mockReturnValue(true); + (confirmDelete as any).mockResolvedValue(true); initTextList(); const link = document.querySelector('[data-action="confirm-delete"]')!; link.click(); + await settle(); expect(confirmDelete).toHaveBeenCalled(); expect(window.location.href).toBe('/texts/delete/5'); }); diff --git a/tests/frontend/texts/text_print.test.ts b/tests/frontend/texts/text_print.test.ts index 8db8fdbf5..702acbb87 100644 --- a/tests/frontend/texts/text_print.test.ts +++ b/tests/frontend/texts/text_print.test.ts @@ -1,6 +1,7 @@ /** * Tests for text_print_app.ts - Alpine.js Text Print Component */ +import { answerConfirms, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { textPrintAppData, type TextPrintAppData } from '../../../src/frontend/js/modules/text/pages/text_print_app'; @@ -379,11 +380,12 @@ describe('text_print_app.ts', () => { expect(() => component.navigateTo('/texts')).not.toThrow(); }); - it('confirmNavigateTo shows confirm dialog', () => { - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + it('confirmNavigateTo shows confirm dialog', async () => { + const confirmSpy = answerConfirms(false); component.confirmNavigateTo('/delete', 'Are you sure?'); + await settle(); expect(confirmSpy).toHaveBeenCalledWith('Are you sure?'); }); diff --git a/tests/frontend/texts/text_reader.test.ts b/tests/frontend/texts/text_reader.test.ts index c60455664..b5579ccc8 100644 --- a/tests/frontend/texts/text_reader.test.ts +++ b/tests/frontend/texts/text_reader.test.ts @@ -1,6 +1,7 @@ /** * Tests for text_reader.ts - Text reading view Alpine component */ +import { answerConfirms, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Mock Alpine.js @@ -433,29 +434,32 @@ describe('text_reader.ts', () => { describe('markAllWellKnown', () => { it('shows confirmation dialog', async () => { - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + const confirmSpy = answerConfirms(false); const component = textReaderData(); await component.markAllWellKnown(); + await settle(); expect(confirmSpy).toHaveBeenCalled(); }); it('does nothing when cancelled', async () => { - vi.spyOn(window, 'confirm').mockReturnValue(false); + answerConfirms(false); const component = textReaderData(); await component.markAllWellKnown(); + await settle(); expect(TextsApi.markAllWellKnown).not.toHaveBeenCalled(); }); it('calls API when confirmed', async () => { - vi.spyOn(window, 'confirm').mockReturnValue(true); + answerConfirms(true); const component = textReaderData(); await component.markAllWellKnown(); + await settle(); expect(TextsApi.markAllWellKnown).toHaveBeenCalledWith(123); }); }); @@ -466,29 +470,32 @@ describe('text_reader.ts', () => { describe('markAllIgnored', () => { it('shows confirmation dialog', async () => { - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + const confirmSpy = answerConfirms(false); const component = textReaderData(); await component.markAllIgnored(); + await settle(); expect(confirmSpy).toHaveBeenCalled(); }); it('does nothing when cancelled', async () => { - vi.spyOn(window, 'confirm').mockReturnValue(false); + answerConfirms(false); const component = textReaderData(); await component.markAllIgnored(); + await settle(); expect(TextsApi.markAllIgnored).not.toHaveBeenCalled(); }); it('calls API when confirmed', async () => { - vi.spyOn(window, 'confirm').mockReturnValue(true); + answerConfirms(true); const component = textReaderData(); await component.markAllIgnored(); + await settle(); expect(TextsApi.markAllIgnored).toHaveBeenCalledWith(123); }); }); @@ -712,7 +719,7 @@ describe('text_reader.ts', () => { describe('markAllWellKnown edge cases', () => { it('handles API error response', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.spyOn(window, 'confirm').mockReturnValue(true); + answerConfirms(true); vi.mocked(TextsApi.markAllWellKnown).mockResolvedValue({ error: 'API Error', @@ -722,12 +729,13 @@ describe('text_reader.ts', () => { const component = textReaderData(); await component.markAllWellKnown(); + await settle(); expect(consoleSpy).toHaveBeenCalledWith('Failed to mark all well-known:', 'API Error'); expect(component.statusMessage).toBe('Failed to mark words as well-known.'); }); it('updates words in DOM and store on success', async () => { - vi.spyOn(window, 'confirm').mockReturnValue(true); + answerConfirms(true); vi.mocked(TextsApi.markAllWellKnown).mockResolvedValue({ data: { @@ -742,6 +750,7 @@ describe('text_reader.ts', () => { const component = textReaderData(); await component.markAllWellKnown(); + await settle(); expect(updateWordStatusInDOM).toHaveBeenCalledWith('ABC123', 99, 1); expect(updateWordStatusInDOM).toHaveBeenCalledWith('DEF456', 99, 2); expect(mockWordStore.updateWordInStore).toHaveBeenCalledTimes(2); @@ -750,13 +759,14 @@ describe('text_reader.ts', () => { it('handles exception during API call', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.spyOn(window, 'confirm').mockReturnValue(true); + answerConfirms(true); vi.mocked(TextsApi.markAllWellKnown).mockRejectedValue(new Error('Network error')); const component = textReaderData(); await component.markAllWellKnown(); + await settle(); expect(consoleSpy).toHaveBeenCalledWith('Error marking all well-known:', expect.any(Error)); expect(component.statusMessage).toBe('Error marking words as well-known.'); }); @@ -769,7 +779,7 @@ describe('text_reader.ts', () => { describe('markAllIgnored edge cases', () => { it('handles API error response', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.spyOn(window, 'confirm').mockReturnValue(true); + answerConfirms(true); vi.mocked(TextsApi.markAllIgnored).mockResolvedValue({ error: 'API Error', @@ -779,12 +789,13 @@ describe('text_reader.ts', () => { const component = textReaderData(); await component.markAllIgnored(); + await settle(); expect(consoleSpy).toHaveBeenCalledWith('Failed to mark all ignored:', 'API Error'); expect(component.statusMessage).toBe('Failed to mark words as ignored.'); }); it('updates words in DOM and store on success', async () => { - vi.spyOn(window, 'confirm').mockReturnValue(true); + answerConfirms(true); vi.mocked(TextsApi.markAllIgnored).mockResolvedValue({ data: { @@ -799,6 +810,7 @@ describe('text_reader.ts', () => { const component = textReaderData(); await component.markAllIgnored(); + await settle(); expect(updateWordStatusInDOM).toHaveBeenCalledWith('ABC123', 98, 1); expect(updateWordStatusInDOM).toHaveBeenCalledWith('DEF456', 98, 2); expect(mockWordStore.updateWordInStore).toHaveBeenCalledTimes(2); @@ -807,13 +819,14 @@ describe('text_reader.ts', () => { it('handles exception during API call', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.spyOn(window, 'confirm').mockReturnValue(true); + answerConfirms(true); vi.mocked(TextsApi.markAllIgnored).mockRejectedValue(new Error('Network error')); const component = textReaderData(); await component.markAllIgnored(); + await settle(); expect(consoleSpy).toHaveBeenCalledWith('Error marking all ignored:', expect.any(Error)); expect(component.statusMessage).toBe('Error marking words as ignored.'); }); diff --git a/tests/frontend/texts/texts_grouped_app.test.ts b/tests/frontend/texts/texts_grouped_app.test.ts index b37eba166..ac1c09195 100644 --- a/tests/frontend/texts/texts_grouped_app.test.ts +++ b/tests/frontend/texts/texts_grouped_app.test.ts @@ -573,8 +573,8 @@ describe('texts_grouped_app.ts', () => { expect(confirmDelete).toHaveBeenCalled(); }); - it('resets select when delete cancelled', () => { - (confirmDelete as ReturnType).mockReturnValue(false); + it('resets select when delete cancelled', async () => { + (confirmDelete as ReturnType).mockResolvedValue(false); const component = textsGroupedData(); component.markedTexts = new Set([10]); @@ -582,7 +582,7 @@ describe('texts_grouped_app.ts', () => { const selectEl = { value: 'del' }; const event = { target: selectEl } as unknown as Event; - component.handleMultiAction(event); + await component.handleMultiAction(event); expect(selectEl.value).toBe(''); }); @@ -624,24 +624,24 @@ describe('texts_grouped_app.ts', () => { expect(TextsApi.bulkAction).toHaveBeenCalledWith('archive', [10, 20]); }); - it('routes confirmed delete to the JSON API', () => { - (confirmDelete as ReturnType).mockReturnValue(true); + it('routes confirmed delete to the JSON API', async () => { + (confirmDelete as ReturnType).mockResolvedValue(true); const component = textsGroupedData(); component.markedTexts = new Set([7]); - component.handleMultiAction({ target: { value: 'del' } } as unknown as Event); + await component.handleMultiAction({ target: { value: 'del' } } as unknown as Event); expect(TextsApi.bulkAction).toHaveBeenCalledWith('delete', [7]); }); - it('does not call the API when delete is cancelled', () => { - (confirmDelete as ReturnType).mockReturnValue(false); + it('does not call the API when delete is cancelled', async () => { + (confirmDelete as ReturnType).mockResolvedValue(false); const component = textsGroupedData(); component.markedTexts = new Set([7]); - component.handleMultiAction({ target: { value: 'del' } } as unknown as Event); + await component.handleMultiAction({ target: { value: 'del' } } as unknown as Event); expect(TextsApi.bulkAction).not.toHaveBeenCalled(); }); diff --git a/tests/frontend/ui/confirm_dialog.test.ts b/tests/frontend/ui/confirm_dialog.test.ts index b69a9368b..fa2bc4f3a 100644 --- a/tests/frontend/ui/confirm_dialog.test.ts +++ b/tests/frontend/ui/confirm_dialog.test.ts @@ -2,7 +2,10 @@ * Tests for confirm_dialog.ts - in-app confirmation dialog */ import { describe, it, expect, afterEach, vi } from 'vitest'; -import { confirmDialog } from '../../../src/frontend/js/shared/components/confirm_dialog'; +import { confirmBeforeSubmit, confirmDialog } from '../../../src/frontend/js/shared/components/confirm_dialog'; +import { settle, trackSubmissions } from '../helpers/confirm_answers'; +import { readdirSync, readFileSync, statSync } from 'fs'; +import { join } from 'path'; const options = { title: 'Discard your changes?', @@ -103,6 +106,154 @@ describe('confirm_dialog.ts', () => { expect(document.querySelectorAll('.confirm-dialog')).toHaveLength(1); }); + it('shows only the message when there is no title', () => { + confirmDialog({ ...options, title: undefined }); + + expect(document.querySelector('#confirm-dialog-title')).toBeNull(); + expect(document.querySelector('.confirm-dialog')?.getAttribute('aria-labelledby')).toBeNull(); + expect(document.querySelector('.confirm-dialog')?.textContent).toContain('They have not been saved.'); + }); + + describe('confirmBeforeSubmit', () => { + function formWithHandler(ask: () => Promise): { + form: HTMLFormElement; + after: ReturnType; + } { + document.body.innerHTML = ` +
+ + + +
+ `; + const form = document.querySelector('form') as HTMLFormElement; + const after = vi.fn(); + form.addEventListener('submit', (event) => { + if (confirmBeforeSubmit(event, form, ask)) after(); + }); + return { form, after }; + } + + it('holds the submission until the user answers', async () => { + const submissions = trackSubmissions(); + let answer: (confirmed: boolean) => void = () => undefined; + const { form, after } = formWithHandler(() => new Promise((resolve) => { answer = resolve; })); + + form.requestSubmit(); + await settle(); + + expect(submissions).toHaveLength(0); + expect(after).not.toHaveBeenCalled(); + + answer(true); + await settle(); + + expect(submissions).toHaveLength(1); + expect(after).toHaveBeenCalledOnce(); + }); + + it('never submits when declined', async () => { + const submissions = trackSubmissions(); + const { form, after } = formWithHandler(() => Promise.resolve(false)); + + form.requestSubmit(); + await settle(); + + expect(submissions).toHaveLength(0); + expect(after).not.toHaveBeenCalled(); + }); + + it('keeps the button that was pressed', async () => { + const { form } = formWithHandler(() => Promise.resolve(true)); + const restore = document.getElementById('restore') as HTMLButtonElement; + const resubmit = vi.spyOn(form, 'requestSubmit').mockImplementation(() => undefined); + const event = new Event('submit', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'submitter', { value: restore }); + + form.dispatchEvent(event); + await settle(); + + expect(resubmit).toHaveBeenCalledWith(restore); + }); + + it('asks again each time', async () => { + const ask = vi.fn().mockResolvedValue(true); + const { form } = formWithHandler(ask); + vi.spyOn(form, 'requestSubmit').mockImplementation(() => undefined); + const submit = () => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + + submit(); + await settle(); + submit(); + await settle(); + + expect(ask).toHaveBeenCalledTimes(2); + }); + + it('asks again after the browser refused the confirmed submission', async () => { + const ask = vi.fn().mockResolvedValue(true); + const { form } = formWithHandler(ask); + // A failed validation fires no submit event. + vi.spyOn(form, 'requestSubmit').mockImplementation(() => undefined); + const submit = () => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + + submit(); + await settle(); + submit(); + await settle(); + + expect(ask).toHaveBeenCalledTimes(2); + }); + }); + + it('is the only code that opens a native confirmation', () => { + const offenders: string[] = []; + const walk = (dir: string): void => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + walk(path); + } else if (path.endsWith('.ts') && !path.endsWith('confirm_dialog.ts')) { + if (/(^|[^.\w])(window\.)?confirm\(/m.test(readFileSync(path, 'utf8'))) { + offenders.push(path); + } + } + } + }; + + walk('src/frontend/js'); + + expect(offenders).toEqual([]); + }); + + it('never tests a pending confirmation for truth without awaiting it', () => { + // A promise is always truthy, so `if (confirmDelete())` would act without + // asking, and `!confirmDelete()` would never run. The compiler catches + // only some of these. + const offenders: string[] = []; + const asks = /(confirmAction|confirmDeletion|confirmDialog|confirmDelete|confirmDiscardChanges|confirmSubmit)\(/; + const walk = (dir: string): void => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + walk(path); + } else if (path.endsWith('.ts')) { + readFileSync(path, 'utf8').split('\n').forEach((line, index) => { + const handedOver = line.includes('confirmBeforeSubmit('); + if (/\b(if|while)\b|&&|\|\||!\s*confirm/.test(line) && asks.test(line) + && !line.includes('await ') && !handedOver) { + offenders.push(`${path}:${index + 1}`); + } + }); + } + } + }; + + walk('src/frontend/js'); + + expect(offenders).toEqual([]); + }); + it('falls back to the native dialog when it cannot be built', async () => { vi.spyOn(document, 'createElement').mockImplementation(() => { throw new Error('no DOM'); diff --git a/tests/frontend/vocabulary/word_modal.test.ts b/tests/frontend/vocabulary/word_modal.test.ts index 879bf5f2f..acc0618d1 100644 --- a/tests/frontend/vocabulary/word_modal.test.ts +++ b/tests/frontend/vocabulary/word_modal.test.ts @@ -1,6 +1,7 @@ /** * Tests for word_modal.ts - Alpine.js component for Bulma word edit modal. */ +import { answerConfirms, confirmPrompts, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Alpine from 'alpinejs'; @@ -9,7 +10,8 @@ const mockSpeechDispatcher = vi.hoisted(() => vi.fn()); // In-app confirmation const mockConfirmDiscard = vi.hoisted(() => vi.fn()); -vi.mock('../../../src/frontend/js/shared/components/confirm_dialog', () => ({ +vi.mock('../../../src/frontend/js/shared/components/confirm_dialog', async (importOriginal) => ({ + ...await importOriginal(), confirmDiscardChanges: mockConfirmDiscard })); @@ -103,7 +105,7 @@ describe('word_modal.ts', () => { }); // Mock confirm - global.confirm = vi.fn().mockReturnValue(true); + answerConfirms(true); }); afterEach(() => { @@ -300,7 +302,7 @@ describe('word_modal.ts', () => { await wordModalData().close(); - expect(confirm).not.toHaveBeenCalled(); + expect(confirmPrompts).not.toHaveBeenCalled(); expect(mockFormStore.reset).toHaveBeenCalled(); expect(mockWordStore.closeEditModal).toHaveBeenCalled(); }); @@ -403,21 +405,23 @@ describe('word_modal.ts', () => { describe('deleteWord', () => { it('confirms and calls store.deleteWord when user confirms', async () => { - vi.mocked(confirm).mockReturnValue(true); + answerConfirms(true); const component = wordModalData(); await component.deleteWord(); - expect(confirm).toHaveBeenCalledWith('Delete this term?'); + await settle(); + expect(confirmPrompts).toHaveBeenCalledWith('Delete this term?'); expect(mockWordStore.deleteWord).toHaveBeenCalledWith('abc123'); }); it('does not delete when user cancels', async () => { - vi.mocked(confirm).mockReturnValue(false); + answerConfirms(false); const component = wordModalData(); await component.deleteWord(); + await settle(); expect(mockWordStore.deleteWord).not.toHaveBeenCalled(); }); @@ -427,7 +431,7 @@ describe('word_modal.ts', () => { await component.deleteWord(); - expect(confirm).not.toHaveBeenCalled(); + expect(confirmPrompts).not.toHaveBeenCalled(); expect(mockWordStore.deleteWord).not.toHaveBeenCalled(); }); }); diff --git a/tests/frontend/vocabulary/word_popover.test.ts b/tests/frontend/vocabulary/word_popover.test.ts index 2c4986590..287614c8c 100644 --- a/tests/frontend/vocabulary/word_popover.test.ts +++ b/tests/frontend/vocabulary/word_popover.test.ts @@ -1,6 +1,7 @@ /** * Tests for word_popover.ts - Alpine.js component for non-blocking word info display. */ +import { answerConfirms, confirmPrompts, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Alpine from 'alpinejs'; @@ -96,7 +97,7 @@ describe('word_popover.ts', () => { }); // Mock confirm - global.confirm = vi.fn().mockReturnValue(true); + answerConfirms(true); // Mock window dimensions Object.defineProperty(window, 'innerHeight', { value: 768, writable: true }); @@ -391,21 +392,23 @@ describe('word_popover.ts', () => { describe('deleteWord', () => { it('confirms and calls store.deleteWord when user confirms', async () => { - vi.mocked(confirm).mockReturnValue(true); + answerConfirms(true); const component = wordPopoverData(); await component.deleteWord(); - expect(confirm).toHaveBeenCalledWith('Delete this term?'); + await settle(); + expect(confirmPrompts).toHaveBeenCalledWith('Delete this term?'); expect(mockStore.deleteWord).toHaveBeenCalledWith('abc123'); }); it('does not delete when user cancels', async () => { - vi.mocked(confirm).mockReturnValue(false); + answerConfirms(false); const component = wordPopoverData(); await component.deleteWord(); + await settle(); expect(mockStore.deleteWord).not.toHaveBeenCalled(); }); @@ -415,7 +418,7 @@ describe('word_popover.ts', () => { await component.deleteWord(); - expect(confirm).not.toHaveBeenCalled(); + expect(confirmPrompts).not.toHaveBeenCalled(); }); }); diff --git a/tests/frontend/words/word_list_app.test.ts b/tests/frontend/words/word_list_app.test.ts index b3dda29bc..89052ddfd 100644 --- a/tests/frontend/words/word_list_app.test.ts +++ b/tests/frontend/words/word_list_app.test.ts @@ -1,6 +1,7 @@ /** * Tests for words/word_list_app.ts - Word List Alpine.js component */ +import { answerConfirms, confirmPrompts } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { wordListData, initWordListAlpine } from '../../../src/frontend/js/modules/vocabulary/pages/word_list_app'; @@ -54,7 +55,7 @@ describe('words/word_list_app.ts', () => { // Mock alert and confirm vi.spyOn(window, 'alert').mockImplementation(() => {}); - vi.spyOn(window, 'confirm').mockReturnValue(true); + answerConfirms(true); vi.spyOn(window, 'prompt').mockReturnValue('test-tag'); vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); @@ -559,7 +560,7 @@ describe('words/word_list_app.ts', () => { const data = wordListData(); data.marked.add(1); - vi.spyOn(window, 'confirm').mockReturnValue(false); + answerConfirms(false); const select = document.createElement('select'); select.value = 'del'; @@ -586,7 +587,7 @@ describe('words/word_list_app.ts', () => { await data.handleAllAction(event); - expect(window.confirm).not.toHaveBeenCalled(); + expect(confirmPrompts).not.toHaveBeenCalled(); }); });