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