diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d3dbaec3..d38a1b40e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,12 @@ ones are marked like "v1.0.0-fork". ### Fixed in Unreleased -* 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. +* Confirmations, alerts and text prompts are now in-app dialogs that follow the + theme and the interface language, instead of the browser's, and messages that + were English in every locale are translated. Entering an invalid tag shows + what is wrong without closing the dialog. The browser's own dialogs remain + only as a fallback, and for the warning when leaving a page with unsaved + changes. * Cancelling a database restore no longer leaves the page showing "restoring". diff --git a/locale/ar/common.json b/locale/ar/common.json index 6196822a6..6d5c4b34f 100644 --- a/locale/ar/common.json +++ b/locale/ar/common.json @@ -63,5 +63,33 @@ "dialog.translation_exists": "يبدو أن «{text}» موجودة بالفعل كترجمة. هل تريد إدراجها على أي حال؟", "dialog.records_affected": "السجلات المتأثرة: {count}", "dialog.all_records_warning": "ينطبق هذا على كل السجلات في كل صفحات الاستعلام الحالي.", - "dialog.delete_table_set": "هل تريد حذف مجموعة الجداول «{name}»؟ ستُفقد كل بياناتها." + "dialog.delete_table_set": "هل تريد حذف مجموعة الجداول «{name}»؟ ستُفقد كل بياناتها.", + "dialog.ok": "موافق", + "dialog.error": "خطأ", + "dialog.enter_tag": "أدخل وسمًا واحدًا (20 حرفًا كحد أقصى، بدون مسافات أو فواصل).", + "dialog.tag_invalid": "يمكن أن يتكون الوسم من 20 حرفًا كحد أقصى، بدون مسافات أو فواصل.", + "dialog.mark_failed": "تعذّر وضع علامة على الكلمات. يُرجى المحاولة مرة أخرى.", + "dialog.delete_failed": "تعذّر الحذف. يُرجى المحاولة مرة أخرى.", + "dialog.action_failed": "فشل الإجراء. يُرجى المحاولة مرة أخرى.", + "dialog.save_failed": "تعذّر الحفظ. يُرجى المحاولة مرة أخرى.", + "dialog.delete_failed_reason": "فشل الحذف: {reason}", + "dialog.selection_too_long": "النص المحدد طويل جدًا.", + "dialog.no_speech": "لا يدعم متصفحك تحويل النص إلى كلام.", + "dialog.translation_not_copied": "تعذّر نسخ الترجمة.", + "dialog.text_field_empty": "حقل النص فارغ أو يحتوي على «*» فقط.", + "dialog.term_save_failed": "تعذّر حفظ تغييراتك. يُرجى إعادة تحميل الصفحة والمحاولة مرة أخرى. الخطأ: «{error}»", + "dialog.translation_update_failed": "تعذّر تحديث الترجمة. يُرجى إعادة تحميل الصفحة والمحاولة مرة أخرى.", + "dialog.translation_add_failed": "تعذّرت إضافة الترجمة. يُرجى إعادة تحميل الصفحة والمحاولة مرة أخرى.", + "dialog.language_exists": "اللغة «{name}» موجودة بالفعل. يُرجى اختيار اسم آخر.", + "dialog.table_prefix_invalid": "يجب أن يتكون اسم مجموعة الجداول من 1 إلى 20 حرفًا (0-9 وa-z وA-Z و_ فقط).", + "dialog.fields_empty": "الحقول المطلوبة الفارغة المميزة بـ *: {count}", + "dialog.field_url": "يجب أن يبدأ الحقل «{field}» بـ «http://» أو «https://».", + "dialog.field_max": "الحد الأقصى لقيمة الحقل «{field}» هو {max}.", + "dialog.field_dict_url": "يجب أن يكون الحقل «{field}» عنوان URL.", + "dialog.field_positive": "يجب أن يكون الحقل «{field}» عددًا صحيحًا أكبر من 0.", + "dialog.field_nonnegative": "يجب أن يكون الحقل «{field}» عددًا صحيحًا يساوي 0 أو أكبر.", + "dialog.field_too_long_chars": "النص في الحقل «{field}» طويل جدًا. الحد الأقصى: {max} حرفًا.", + "dialog.field_too_long_bytes": "النص في الحقل «{field}» طويل جدًا. الحد الأقصى: {max} بايت.", + "dialog.field_no_spaces": "يجب ألا يحتوي الحقل «{field}» على مسافات أو فواصل.", + "dialog.field_unsupported_char": "يحتوي الحقل «{field}» على حرف غير مدعوم، مثل رمز تعبيري: «{char}» في الموضع {position}. يُرجى إزالته والمحاولة مرة أخرى." } diff --git a/locale/de/common.json b/locale/de/common.json index 4263477e2..5a725ed37 100644 --- a/locale/de/common.json +++ b/locale/de/common.json @@ -63,5 +63,33 @@ "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." + "dialog.delete_table_set": "Tabellensatz „{name}“ löschen? Alle seine Daten gehen verloren.", + "dialog.ok": "OK", + "dialog.error": "Fehler", + "dialog.enter_tag": "Geben Sie ein Tag ein (höchstens 20 Zeichen, keine Leerzeichen oder Kommas).", + "dialog.tag_invalid": "Ein Tag darf höchstens 20 Zeichen haben, ohne Leerzeichen oder Kommas.", + "dialog.mark_failed": "Wörter konnten nicht markiert werden. Bitte versuchen Sie es erneut.", + "dialog.delete_failed": "Löschen fehlgeschlagen. Bitte versuchen Sie es erneut.", + "dialog.action_failed": "Aktion fehlgeschlagen. Bitte versuchen Sie es erneut.", + "dialog.save_failed": "Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.", + "dialog.delete_failed_reason": "Löschen fehlgeschlagen: {reason}", + "dialog.selection_too_long": "Der ausgewählte Text ist zu lang.", + "dialog.no_speech": "Ihr Browser unterstützt keine Sprachausgabe.", + "dialog.translation_not_copied": "Die Übersetzung konnte nicht kopiert werden.", + "dialog.text_field_empty": "Das Textfeld ist leer oder enthält nur „*“.", + "dialog.term_save_failed": "Ihre Änderungen konnten nicht gespeichert werden. Bitte laden Sie die Seite neu und versuchen Sie es erneut. Fehler: „{error}“", + "dialog.translation_update_failed": "Die Übersetzung konnte nicht aktualisiert werden. Bitte laden Sie die Seite neu und versuchen Sie es erneut.", + "dialog.translation_add_failed": "Die Übersetzung konnte nicht hinzugefügt werden. Bitte laden Sie die Seite neu und versuchen Sie es erneut.", + "dialog.language_exists": "Die Sprache „{name}“ existiert bereits. Bitte wählen Sie einen anderen Namen.", + "dialog.table_prefix_invalid": "Der Tabellensatzname muss 1 bis 20 Zeichen haben (nur 0-9, a-z, A-Z und _).", + "dialog.fields_empty": "Leere Pflichtfelder (mit * markiert): {count}", + "dialog.field_url": "Das Feld „{field}“ muss mit „http://“ oder „https://“ beginnen.", + "dialog.field_max": "Der Höchstwert des Feldes „{field}“ ist {max}.", + "dialog.field_dict_url": "Das Feld „{field}“ muss eine URL sein.", + "dialog.field_positive": "Das Feld „{field}“ muss eine ganze Zahl größer als 0 sein.", + "dialog.field_nonnegative": "Das Feld „{field}“ muss eine ganze Zahl ab 0 sein.", + "dialog.field_too_long_chars": "Der Text im Feld „{field}“ ist zu lang. Maximum: {max} Zeichen.", + "dialog.field_too_long_bytes": "Der Text im Feld „{field}“ ist zu lang. Maximum: {max} Byte.", + "dialog.field_no_spaces": "Das Feld „{field}“ darf keine Leerzeichen oder Kommas enthalten.", + "dialog.field_unsupported_char": "Das Feld „{field}“ enthält ein nicht unterstütztes Zeichen, etwa ein Emoji: „{char}“ an Position {position}. Bitte entfernen Sie es und versuchen Sie es erneut." } diff --git a/locale/en/common.json b/locale/en/common.json index a6a8fdd27..e37ed4477 100644 --- a/locale/en/common.json +++ b/locale/en/common.json @@ -63,5 +63,33 @@ "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." + "dialog.delete_table_set": "Delete table set \"{name}\"? All its data will be lost.", + "dialog.ok": "OK", + "dialog.error": "Error", + "dialog.enter_tag": "Enter one tag (20 characters at most, no spaces or commas).", + "dialog.tag_invalid": "A tag can have 20 characters at most, with no spaces or commas.", + "dialog.mark_failed": "Failed to mark words. Please try again.", + "dialog.delete_failed": "Failed to delete. Please try again.", + "dialog.action_failed": "Action failed. Please try again.", + "dialog.save_failed": "Failed to save. Please try again.", + "dialog.delete_failed_reason": "Delete failed: {reason}", + "dialog.selection_too_long": "The selected text is too long.", + "dialog.no_speech": "Your browser does not support speech synthesis.", + "dialog.translation_not_copied": "The translation could not be copied.", + "dialog.text_field_empty": "The text field is empty or contains only \"*\".", + "dialog.term_save_failed": "Saving your changes failed. Please reload the page and try again. Error: \"{error}\"", + "dialog.translation_update_failed": "Updating the translation failed. Please reload the page and try again.", + "dialog.translation_add_failed": "Adding the translation failed. Please reload the page and try again.", + "dialog.language_exists": "The language \"{name}\" already exists. Please choose another name.", + "dialog.table_prefix_invalid": "The table set name must have 1 to 20 characters (only 0-9, a-z, A-Z and _).", + "dialog.fields_empty": "Required fields marked with * are empty: {count}", + "dialog.field_url": "Field \"{field}\" must start with \"http://\" or \"https://\".", + "dialog.field_max": "The maximum value of field \"{field}\" is {max}.", + "dialog.field_dict_url": "Field \"{field}\" must be a URL.", + "dialog.field_positive": "Field \"{field}\" must be a whole number greater than 0.", + "dialog.field_nonnegative": "Field \"{field}\" must be a whole number, 0 or greater.", + "dialog.field_too_long_chars": "The text in field \"{field}\" is too long. Maximum: {max} characters.", + "dialog.field_too_long_bytes": "The text in field \"{field}\" is too long. Maximum: {max} bytes.", + "dialog.field_no_spaces": "Field \"{field}\" must not contain spaces or commas.", + "dialog.field_unsupported_char": "Field \"{field}\" contains a character that is not supported, such as an emoji: \"{char}\" at position {position}. Please remove it and try again." } diff --git a/locale/es/common.json b/locale/es/common.json index 3dbc74c13..2adabf4b2 100644 --- a/locale/es/common.json +++ b/locale/es/common.json @@ -63,5 +63,33 @@ "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." + "dialog.delete_table_set": "¿Eliminar el conjunto de tablas «{name}»? Se perderán todos sus datos.", + "dialog.ok": "Aceptar", + "dialog.error": "Error", + "dialog.enter_tag": "Introduzca una etiqueta (20 caracteres como máximo, sin espacios ni comas).", + "dialog.tag_invalid": "Una etiqueta puede tener 20 caracteres como máximo, sin espacios ni comas.", + "dialog.mark_failed": "No se pudieron marcar las palabras. Inténtelo de nuevo.", + "dialog.delete_failed": "No se pudo eliminar. Inténtelo de nuevo.", + "dialog.action_failed": "La acción falló. Inténtelo de nuevo.", + "dialog.save_failed": "No se pudo guardar. Inténtelo de nuevo.", + "dialog.delete_failed_reason": "Error al eliminar: {reason}", + "dialog.selection_too_long": "El texto seleccionado es demasiado largo.", + "dialog.no_speech": "Su navegador no admite la síntesis de voz.", + "dialog.translation_not_copied": "No se pudo copiar la traducción.", + "dialog.text_field_empty": "El campo de texto está vacío o solo contiene «*».", + "dialog.term_save_failed": "No se pudieron guardar los cambios. Recargue la página e inténtelo de nuevo. Error: «{error}»", + "dialog.translation_update_failed": "No se pudo actualizar la traducción. Recargue la página e inténtelo de nuevo.", + "dialog.translation_add_failed": "No se pudo añadir la traducción. Recargue la página e inténtelo de nuevo.", + "dialog.language_exists": "El idioma «{name}» ya existe. Elija otro nombre.", + "dialog.table_prefix_invalid": "El nombre del conjunto de tablas debe tener de 1 a 20 caracteres (solo 0-9, a-z, A-Z y _).", + "dialog.fields_empty": "Campos obligatorios marcados con * vacíos: {count}", + "dialog.field_url": "El campo «{field}» debe empezar por «http://» o «https://».", + "dialog.field_max": "El valor máximo del campo «{field}» es {max}.", + "dialog.field_dict_url": "El campo «{field}» debe ser una URL.", + "dialog.field_positive": "El campo «{field}» debe ser un número entero mayor que 0.", + "dialog.field_nonnegative": "El campo «{field}» debe ser un número entero igual o mayor que 0.", + "dialog.field_too_long_chars": "El texto del campo «{field}» es demasiado largo. Máximo: {max} caracteres.", + "dialog.field_too_long_bytes": "El texto del campo «{field}» es demasiado largo. Máximo: {max} bytes.", + "dialog.field_no_spaces": "El campo «{field}» no debe contener espacios ni comas.", + "dialog.field_unsupported_char": "El campo «{field}» contiene un carácter no admitido, como un emoji: «{char}» en la posición {position}. Elimínelo e inténtelo de nuevo." } diff --git a/locale/fa/common.json b/locale/fa/common.json index e43043b40..fcae0eb58 100644 --- a/locale/fa/common.json +++ b/locale/fa/common.json @@ -63,5 +63,33 @@ "dialog.translation_exists": "به نظر می‌رسد «{text}» از قبل به‌عنوان ترجمه وجود دارد. با این حال درج شود؟", "dialog.records_affected": "رکوردهای تأثیرپذیرفته: {count}", "dialog.all_records_warning": "این کار روی همهٔ رکوردها در همهٔ صفحه‌های پرس‌وجوی فعلی اعمال می‌شود.", - "dialog.delete_table_set": "مجموعه‌جدول «{name}» حذف شود؟ همهٔ داده‌های آن از دست خواهد رفت." + "dialog.delete_table_set": "مجموعه‌جدول «{name}» حذف شود؟ همهٔ داده‌های آن از دست خواهد رفت.", + "dialog.ok": "تأیید", + "dialog.error": "خطا", + "dialog.enter_tag": "یک برچسب وارد کنید (حداکثر ۲۰ نویسه، بدون فاصله یا ویرگول).", + "dialog.tag_invalid": "برچسب می‌تواند حداکثر ۲۰ نویسه داشته باشد و نباید فاصله یا ویرگول داشته باشد.", + "dialog.mark_failed": "علامت‌گذاری واژه‌ها انجام نشد. لطفاً دوباره تلاش کنید.", + "dialog.delete_failed": "حذف انجام نشد. لطفاً دوباره تلاش کنید.", + "dialog.action_failed": "عملیات انجام نشد. لطفاً دوباره تلاش کنید.", + "dialog.save_failed": "ذخیره انجام نشد. لطفاً دوباره تلاش کنید.", + "dialog.delete_failed_reason": "حذف انجام نشد: {reason}", + "dialog.selection_too_long": "متن انتخاب‌شده خیلی طولانی است.", + "dialog.no_speech": "مرورگر شما از تبدیل متن به گفتار پشتیبانی نمی‌کند.", + "dialog.translation_not_copied": "ترجمه کپی نشد.", + "dialog.text_field_empty": "فیلد متن خالی است یا فقط «*» دارد.", + "dialog.term_save_failed": "ذخیرهٔ تغییرات انجام نشد. لطفاً صفحه را دوباره بارگذاری کنید و دوباره تلاش کنید. خطا: «{error}»", + "dialog.translation_update_failed": "به‌روزرسانی ترجمه انجام نشد. لطفاً صفحه را دوباره بارگذاری کنید و دوباره تلاش کنید.", + "dialog.translation_add_failed": "افزودن ترجمه انجام نشد. لطفاً صفحه را دوباره بارگذاری کنید و دوباره تلاش کنید.", + "dialog.language_exists": "زبان «{name}» از قبل وجود دارد. لطفاً نام دیگری انتخاب کنید.", + "dialog.table_prefix_invalid": "نام مجموعه‌جدول باید ۱ تا ۲۰ نویسه باشد (فقط 0-9، a-z، A-Z و _).", + "dialog.fields_empty": "فیلدهای اجباری خالی که با * مشخص شده‌اند: {count}", + "dialog.field_url": "فیلد «{field}» باید با «http://» یا «https://» شروع شود.", + "dialog.field_max": "بیشترین مقدار فیلد «{field}» برابر {max} است.", + "dialog.field_dict_url": "فیلد «{field}» باید یک نشانی URL باشد.", + "dialog.field_positive": "فیلد «{field}» باید عدد صحیح بزرگ‌تر از ۰ باشد.", + "dialog.field_nonnegative": "فیلد «{field}» باید عدد صحیح ۰ یا بزرگ‌تر باشد.", + "dialog.field_too_long_chars": "متن فیلد «{field}» خیلی طولانی است. حداکثر: {max} نویسه.", + "dialog.field_too_long_bytes": "متن فیلد «{field}» خیلی طولانی است. حداکثر: {max} بایت.", + "dialog.field_no_spaces": "فیلد «{field}» نباید فاصله یا ویرگول داشته باشد.", + "dialog.field_unsupported_char": "فیلد «{field}» نویسه‌ای پشتیبانی‌نشده مانند ایموجی دارد: «{char}» در موقعیت {position}. لطفاً آن را حذف کنید و دوباره تلاش کنید." } diff --git a/locale/fr/common.json b/locale/fr/common.json index a0a7f93c0..22c103942 100644 --- a/locale/fr/common.json +++ b/locale/fr/common.json @@ -63,5 +63,33 @@ "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." + "dialog.delete_table_set": "Supprimer le jeu de tables « {name} » ? Toutes ses données seront perdues.", + "dialog.ok": "OK", + "dialog.error": "Erreur", + "dialog.enter_tag": "Saisissez une étiquette (20 caractères au maximum, sans espace ni virgule).", + "dialog.tag_invalid": "Une étiquette compte 20 caractères au maximum, sans espace ni virgule.", + "dialog.mark_failed": "Impossible de marquer les mots. Veuillez réessayer.", + "dialog.delete_failed": "La suppression a échoué. Veuillez réessayer.", + "dialog.action_failed": "L'action a échoué. Veuillez réessayer.", + "dialog.save_failed": "L'enregistrement a échoué. Veuillez réessayer.", + "dialog.delete_failed_reason": "Échec de la suppression : {reason}", + "dialog.selection_too_long": "Le texte sélectionné est trop long.", + "dialog.no_speech": "Votre navigateur ne prend pas en charge la synthèse vocale.", + "dialog.translation_not_copied": "La traduction n'a pas pu être copiée.", + "dialog.text_field_empty": "Le champ de texte est vide ou ne contient que « * ».", + "dialog.term_save_failed": "L'enregistrement de vos modifications a échoué. Rechargez la page et réessayez. Erreur : « {error} »", + "dialog.translation_update_failed": "La mise à jour de la traduction a échoué. Rechargez la page et réessayez.", + "dialog.translation_add_failed": "L'ajout de la traduction a échoué. Rechargez la page et réessayez.", + "dialog.language_exists": "La langue « {name} » existe déjà. Veuillez choisir un autre nom.", + "dialog.table_prefix_invalid": "Le nom du jeu de tables doit compter de 1 à 20 caractères (uniquement 0-9, a-z, A-Z et _).", + "dialog.fields_empty": "Champs obligatoires marqués d'un * vides : {count}", + "dialog.field_url": "Le champ « {field} » doit commencer par « http:// » ou « https:// ».", + "dialog.field_max": "La valeur maximale du champ « {field} » est {max}.", + "dialog.field_dict_url": "Le champ « {field} » doit être une URL.", + "dialog.field_positive": "Le champ « {field} » doit être un nombre entier supérieur à 0.", + "dialog.field_nonnegative": "Le champ « {field} » doit être un nombre entier positif ou nul.", + "dialog.field_too_long_chars": "Le texte du champ « {field} » est trop long. Maximum : {max} caractères.", + "dialog.field_too_long_bytes": "Le texte du champ « {field} » est trop long. Maximum : {max} octets.", + "dialog.field_no_spaces": "Le champ « {field} » ne doit contenir ni espace ni virgule.", + "dialog.field_unsupported_char": "Le champ « {field} » contient un caractère non pris en charge, comme un emoji : « {char} » à la position {position}. Supprimez-le et réessayez." } diff --git a/locale/it/common.json b/locale/it/common.json index 3e36b7bdf..f5b5a0333 100644 --- a/locale/it/common.json +++ b/locale/it/common.json @@ -63,5 +63,33 @@ "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." + "dialog.delete_table_set": "Eliminare il set di tabelle «{name}»? Tutti i suoi dati andranno persi.", + "dialog.ok": "OK", + "dialog.error": "Errore", + "dialog.enter_tag": "Inserisci un tag (massimo 20 caratteri, senza spazi né virgole).", + "dialog.tag_invalid": "Un tag può avere al massimo 20 caratteri, senza spazi né virgole.", + "dialog.mark_failed": "Impossibile segnare le parole. Riprova.", + "dialog.delete_failed": "Eliminazione non riuscita. Riprova.", + "dialog.action_failed": "Azione non riuscita. Riprova.", + "dialog.save_failed": "Salvataggio non riuscito. Riprova.", + "dialog.delete_failed_reason": "Eliminazione non riuscita: {reason}", + "dialog.selection_too_long": "Il testo selezionato è troppo lungo.", + "dialog.no_speech": "Il browser non supporta la sintesi vocale.", + "dialog.translation_not_copied": "Impossibile copiare la traduzione.", + "dialog.text_field_empty": "Il campo di testo è vuoto o contiene solo «*».", + "dialog.term_save_failed": "Salvataggio delle modifiche non riuscito. Ricarica la pagina e riprova. Errore: «{error}»", + "dialog.translation_update_failed": "Aggiornamento della traduzione non riuscito. Ricarica la pagina e riprova.", + "dialog.translation_add_failed": "Aggiunta della traduzione non riuscita. Ricarica la pagina e riprova.", + "dialog.language_exists": "La lingua «{name}» esiste già. Scegli un altro nome.", + "dialog.table_prefix_invalid": "Il nome del set di tabelle deve avere da 1 a 20 caratteri (solo 0-9, a-z, A-Z e _).", + "dialog.fields_empty": "Campi obbligatori contrassegnati con * vuoti: {count}", + "dialog.field_url": "Il campo «{field}» deve iniziare con «http://» o «https://».", + "dialog.field_max": "Il valore massimo del campo «{field}» è {max}.", + "dialog.field_dict_url": "Il campo «{field}» deve essere un URL.", + "dialog.field_positive": "Il campo «{field}» deve essere un numero intero maggiore di 0.", + "dialog.field_nonnegative": "Il campo «{field}» deve essere un numero intero maggiore o uguale a 0.", + "dialog.field_too_long_chars": "Il testo del campo «{field}» è troppo lungo. Massimo: {max} caratteri.", + "dialog.field_too_long_bytes": "Il testo del campo «{field}» è troppo lungo. Massimo: {max} byte.", + "dialog.field_no_spaces": "Il campo «{field}» non deve contenere spazi né virgole.", + "dialog.field_unsupported_char": "Il campo «{field}» contiene un carattere non supportato, come un'emoji: «{char}» alla posizione {position}. Rimuovilo e riprova." } diff --git a/locale/ja/common.json b/locale/ja/common.json index d36656d21..e528199d8 100644 --- a/locale/ja/common.json +++ b/locale/ja/common.json @@ -63,5 +63,33 @@ "dialog.translation_exists": "「{text}」はすでに訳語として登録されているようです。それでも挿入しますか?", "dialog.records_affected": "対象レコード数: {count}", "dialog.all_records_warning": "現在のクエリの全ページにあるすべてのレコードが対象です。", - "dialog.delete_table_set": "テーブルセット「{name}」を削除しますか?すべてのデータが失われます。" + "dialog.delete_table_set": "テーブルセット「{name}」を削除しますか?すべてのデータが失われます。", + "dialog.ok": "OK", + "dialog.error": "エラー", + "dialog.enter_tag": "タグを1つ入力してください(20文字以内、スペースとカンマは不可)。", + "dialog.tag_invalid": "タグは20文字以内で、スペースとカンマは使用できません。", + "dialog.mark_failed": "単語をマークできませんでした。もう一度お試しください。", + "dialog.delete_failed": "削除できませんでした。もう一度お試しください。", + "dialog.action_failed": "操作に失敗しました。もう一度お試しください。", + "dialog.save_failed": "保存できませんでした。もう一度お試しください。", + "dialog.delete_failed_reason": "削除に失敗しました: {reason}", + "dialog.selection_too_long": "選択したテキストが長すぎます。", + "dialog.no_speech": "お使いのブラウザは音声合成に対応していません。", + "dialog.translation_not_copied": "訳語をコピーできませんでした。", + "dialog.text_field_empty": "テキスト欄が空か、「*」のみです。", + "dialog.term_save_failed": "変更を保存できませんでした。ページを再読み込みして、もう一度お試しください。エラー:「{error}」", + "dialog.translation_update_failed": "訳語を更新できませんでした。ページを再読み込みして、もう一度お試しください。", + "dialog.translation_add_failed": "訳語を追加できませんでした。ページを再読み込みして、もう一度お試しください。", + "dialog.language_exists": "言語「{name}」はすでに存在します。別の名前を選んでください。", + "dialog.table_prefix_invalid": "テーブルセット名は1〜20文字にしてください(0-9、a-z、A-Z、_ のみ)。", + "dialog.fields_empty": "* の付いた必須項目が空です: {count}", + "dialog.field_url": "項目「{field}」は「http://」または「https://」で始める必要があります。", + "dialog.field_max": "項目「{field}」の最大値は {max} です。", + "dialog.field_dict_url": "項目「{field}」はURLである必要があります。", + "dialog.field_positive": "項目「{field}」は0より大きい整数である必要があります。", + "dialog.field_nonnegative": "項目「{field}」は0以上の整数である必要があります。", + "dialog.field_too_long_chars": "項目「{field}」のテキストが長すぎます。最大 {max} 文字です。", + "dialog.field_too_long_bytes": "項目「{field}」のテキストが長すぎます。最大 {max} バイトです。", + "dialog.field_no_spaces": "項目「{field}」にスペースやカンマは使用できません。", + "dialog.field_unsupported_char": "項目「{field}」に絵文字などの非対応文字が含まれています: 「{char}」(位置 {position})。削除してもう一度お試しください。" } diff --git a/locale/pt/common.json b/locale/pt/common.json index aad613390..29dcd0941 100644 --- a/locale/pt/common.json +++ b/locale/pt/common.json @@ -63,5 +63,33 @@ "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." + "dialog.delete_table_set": "Eliminar o conjunto de tabelas «{name}»? Todos os seus dados serão perdidos.", + "dialog.ok": "OK", + "dialog.error": "Erro", + "dialog.enter_tag": "Introduza uma etiqueta (20 caracteres no máximo, sem espaços nem vírgulas).", + "dialog.tag_invalid": "Uma etiqueta pode ter 20 caracteres no máximo, sem espaços nem vírgulas.", + "dialog.mark_failed": "Não foi possível marcar as palavras. Tente novamente.", + "dialog.delete_failed": "Não foi possível eliminar. Tente novamente.", + "dialog.action_failed": "A ação falhou. Tente novamente.", + "dialog.save_failed": "Não foi possível guardar. Tente novamente.", + "dialog.delete_failed_reason": "Falha ao eliminar: {reason}", + "dialog.selection_too_long": "O texto selecionado é demasiado longo.", + "dialog.no_speech": "O seu navegador não suporta síntese de voz.", + "dialog.translation_not_copied": "Não foi possível copiar a tradução.", + "dialog.text_field_empty": "O campo de texto está vazio ou contém apenas «*».", + "dialog.term_save_failed": "Não foi possível guardar as alterações. Recarregue a página e tente novamente. Erro: «{error}»", + "dialog.translation_update_failed": "Não foi possível atualizar a tradução. Recarregue a página e tente novamente.", + "dialog.translation_add_failed": "Não foi possível adicionar a tradução. Recarregue a página e tente novamente.", + "dialog.language_exists": "O idioma «{name}» já existe. Escolha outro nome.", + "dialog.table_prefix_invalid": "O nome do conjunto de tabelas deve ter de 1 a 20 caracteres (apenas 0-9, a-z, A-Z e _).", + "dialog.fields_empty": "Campos obrigatórios marcados com * vazios: {count}", + "dialog.field_url": "O campo «{field}» deve começar por «http://» ou «https://».", + "dialog.field_max": "O valor máximo do campo «{field}» é {max}.", + "dialog.field_dict_url": "O campo «{field}» deve ser um URL.", + "dialog.field_positive": "O campo «{field}» deve ser um número inteiro maior que 0.", + "dialog.field_nonnegative": "O campo «{field}» deve ser um número inteiro igual ou maior que 0.", + "dialog.field_too_long_chars": "O texto do campo «{field}» é demasiado longo. Máximo: {max} caracteres.", + "dialog.field_too_long_bytes": "O texto do campo «{field}» é demasiado longo. Máximo: {max} bytes.", + "dialog.field_no_spaces": "O campo «{field}» não deve conter espaços nem vírgulas.", + "dialog.field_unsupported_char": "O campo «{field}» contém um caráter não suportado, como um emoji: «{char}» na posição {position}. Remova-o e tente novamente." } diff --git a/locale/ru/common.json b/locale/ru/common.json index 2bfd3d640..7f8d7f4da 100644 --- a/locale/ru/common.json +++ b/locale/ru/common.json @@ -63,5 +63,33 @@ "dialog.translation_exists": "«{text}», похоже, уже есть среди переводов. Всё равно добавить?", "dialog.records_affected": "Затронуто записей: {count}", "dialog.all_records_warning": "Это относится ко всем записям на всех страницах текущего запроса.", - "dialog.delete_table_set": "Удалить набор таблиц «{name}»? Все его данные будут потеряны." + "dialog.delete_table_set": "Удалить набор таблиц «{name}»? Все его данные будут потеряны.", + "dialog.ok": "ОК", + "dialog.error": "Ошибка", + "dialog.enter_tag": "Введите один тег (не более 20 символов, без пробелов и запятых).", + "dialog.tag_invalid": "Тег может содержать не более 20 символов, без пробелов и запятых.", + "dialog.mark_failed": "Не удалось отметить слова. Попробуйте ещё раз.", + "dialog.delete_failed": "Не удалось удалить. Попробуйте ещё раз.", + "dialog.action_failed": "Действие не выполнено. Попробуйте ещё раз.", + "dialog.save_failed": "Не удалось сохранить. Попробуйте ещё раз.", + "dialog.delete_failed_reason": "Не удалось удалить: {reason}", + "dialog.selection_too_long": "Выделенный текст слишком длинный.", + "dialog.no_speech": "Ваш браузер не поддерживает синтез речи.", + "dialog.translation_not_copied": "Не удалось скопировать перевод.", + "dialog.text_field_empty": "Текстовое поле пусто или содержит только «*».", + "dialog.term_save_failed": "Не удалось сохранить изменения. Перезагрузите страницу и попробуйте ещё раз. Ошибка: «{error}»", + "dialog.translation_update_failed": "Не удалось обновить перевод. Перезагрузите страницу и попробуйте ещё раз.", + "dialog.translation_add_failed": "Не удалось добавить перевод. Перезагрузите страницу и попробуйте ещё раз.", + "dialog.language_exists": "Язык «{name}» уже существует. Выберите другое название.", + "dialog.table_prefix_invalid": "Имя набора таблиц должно содержать от 1 до 20 символов (только 0-9, a-z, A-Z и _).", + "dialog.fields_empty": "Незаполненных обязательных полей, отмеченных *: {count}", + "dialog.field_url": "Поле «{field}» должно начинаться с «http://» или «https://».", + "dialog.field_max": "Максимальное значение поля «{field}»: {max}.", + "dialog.field_dict_url": "Поле «{field}» должно быть URL-адресом.", + "dialog.field_positive": "Поле «{field}» должно быть целым числом больше 0.", + "dialog.field_nonnegative": "Поле «{field}» должно быть целым числом не меньше 0.", + "dialog.field_too_long_chars": "Текст в поле «{field}» слишком длинный. Максимум символов: {max}.", + "dialog.field_too_long_bytes": "Текст в поле «{field}» слишком длинный. Максимум байт: {max}.", + "dialog.field_no_spaces": "Поле «{field}» не должно содержать пробелы и запятые.", + "dialog.field_unsupported_char": "Поле «{field}» содержит неподдерживаемый символ, например эмодзи: «{char}» в позиции {position}. Удалите его и попробуйте ещё раз." } diff --git a/locale/zh/common.json b/locale/zh/common.json index 13795c09f..f76264f73 100644 --- a/locale/zh/common.json +++ b/locale/zh/common.json @@ -63,5 +63,33 @@ "dialog.translation_exists": "“{text}”似乎已是一个译文。仍要插入吗?", "dialog.records_affected": "受影响的记录:{count}", "dialog.all_records_warning": "此操作适用于当前查询所有页面上的全部记录。", - "dialog.delete_table_set": "删除表集“{name}”?其所有数据都将丢失。" + "dialog.delete_table_set": "删除表集“{name}”?其所有数据都将丢失。", + "dialog.ok": "确定", + "dialog.error": "错误", + "dialog.enter_tag": "请输入一个标签(最多 20 个字符,不含空格和逗号)。", + "dialog.tag_invalid": "标签最多 20 个字符,不能包含空格和逗号。", + "dialog.mark_failed": "无法标记单词。请重试。", + "dialog.delete_failed": "删除失败。请重试。", + "dialog.action_failed": "操作失败。请重试。", + "dialog.save_failed": "保存失败。请重试。", + "dialog.delete_failed_reason": "删除失败:{reason}", + "dialog.selection_too_long": "所选文本过长。", + "dialog.no_speech": "您的浏览器不支持语音合成。", + "dialog.translation_not_copied": "无法复制译文。", + "dialog.text_field_empty": "文本框为空或仅包含“*”。", + "dialog.term_save_failed": "保存更改失败。请重新加载页面后重试。错误:“{error}”", + "dialog.translation_update_failed": "更新译文失败。请重新加载页面后重试。", + "dialog.translation_add_failed": "添加译文失败。请重新加载页面后重试。", + "dialog.language_exists": "语言“{name}”已存在。请换一个名称。", + "dialog.table_prefix_invalid": "表集名称必须为 1 到 20 个字符(仅限 0-9、a-z、A-Z 和 _)。", + "dialog.fields_empty": "标有 * 的必填字段为空:{count}", + "dialog.field_url": "字段“{field}”必须以“http://”或“https://”开头。", + "dialog.field_max": "字段“{field}”的最大值为 {max}。", + "dialog.field_dict_url": "字段“{field}”必须是 URL。", + "dialog.field_positive": "字段“{field}”必须是大于 0 的整数。", + "dialog.field_nonnegative": "字段“{field}”必须是大于或等于 0 的整数。", + "dialog.field_too_long_chars": "字段“{field}”中的文本过长。最多 {max} 个字符。", + "dialog.field_too_long_bytes": "字段“{field}”中的文本过长。最多 {max} 字节。", + "dialog.field_no_spaces": "字段“{field}”不能包含空格或逗号。", + "dialog.field_unsupported_char": "字段“{field}”包含不受支持的字符(如表情符号):位置 {position} 处的“{char}”。请删除后重试。" } diff --git a/src/frontend/js/modules/language/pages/language_form.ts b/src/frontend/js/modules/language/pages/language_form.ts index 9ea52305a..4399873d3 100644 --- a/src/frontend/js/modules/language/pages/language_form.ts +++ b/src/frontend/js/modules/language/pages/language_form.ts @@ -9,6 +9,8 @@ * @since 3.0.0 */ +import { t } from '@shared/i18n/translator'; +import { showError } from '@shared/components/confirm_dialog'; import { setBoundChecked, setBoundValue } from './bound_fields'; import { onDomReady } from '@shared/utils/dom_ready'; import { getLibreTranslateTranslation } from '@modules/vocabulary/services/translation_api'; @@ -378,10 +380,8 @@ export function checkDuplicateLanguage( if (lgName in allLangs) { if (langId !== allLangs[lgName]) { - alert( - 'Language "' + lgName + '" already exists. Please change the language name!' - ); - lgNameEl?.focus(); + void showError(t('common.dialog.language_exists', { name: lgName })) + .then(() => lgNameEl?.focus()); return false; } } diff --git a/src/frontend/js/modules/language/stores/language_settings.ts b/src/frontend/js/modules/language/stores/language_settings.ts index 997004f30..c94c0e055 100644 --- a/src/frontend/js/modules/language/stores/language_settings.ts +++ b/src/frontend/js/modules/language/stores/language_settings.ts @@ -7,7 +7,7 @@ */ import { t as translate } from '@shared/i18n/translator'; -import { confirmAction } from '@shared/components/confirm_dialog'; +import { confirmAction, showError } from '@shared/components/confirm_dialog'; import { onDomReady } from '@shared/utils/dom_ready'; import { apiPut, getCsrfToken } from '@shared/api/client'; @@ -191,7 +191,7 @@ export async function iknowall(t: string | number): Promise { window.location.reload(); } catch (error) { console.error('Failed to mark all words as well-known:', error); - alert('Failed to mark words. Please try again.'); + void showError(translate('common.dialog.mark_failed')); } } @@ -206,11 +206,7 @@ export function validateTablePrefix(p: string): boolean { const re = /^[_a-zA-Z0-9]*$/; const r = p.length <= 20 && p.length > 0 && re.test(p); if (!r) { - alert( - 'Table Set Name (= Table Prefix) must' + - '\ncontain 1 to 20 characters (only 0-9, a-z, A-Z and _).' + - '\nPlease correct your input.' - ); + void showError(translate('common.dialog.table_prefix_invalid')); } return r; } 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 d150a0ae7..900ba5bc3 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 @@ -11,6 +11,8 @@ * @since 3.0.0 */ +import { t } from '@shared/i18n/translator'; +import { showError } from '@shared/components/confirm_dialog'; import Alpine from 'alpinejs'; import { initIcons } from '@shared/icons/lucide_icons'; import { apiGet, getCsrfToken } from '@shared/api/client'; @@ -446,7 +448,7 @@ export function archivedTextsGroupedData(): ArchivedTextsGroupedData { window.location.reload(); }).catch((error) => { console.error('Delete failed:', error); - alert('Failed to delete. Please try again.'); + void showError(t('common.dialog.delete_failed')); }); } }, diff --git a/src/frontend/js/modules/text/pages/reading/text_multiword_selection.ts b/src/frontend/js/modules/text/pages/reading/text_multiword_selection.ts index 6cfd4b9fa..51f1574cf 100644 --- a/src/frontend/js/modules/text/pages/reading/text_multiword_selection.ts +++ b/src/frontend/js/modules/text/pages/reading/text_multiword_selection.ts @@ -8,6 +8,8 @@ * @license Unlicense */ +import { t } from '@shared/i18n/translator'; +import { showError } from '@shared/components/confirm_dialog'; import Alpine from 'alpinejs'; import type { MultiWordFormStoreState } from '@modules/vocabulary/stores/multi_word_form_store'; import { getTextId as getTextIdFromConfig } from '@modules/text/stores/text_config'; @@ -188,7 +190,7 @@ export function handleTextSelection(container: HTMLElement): void { const text = getSelectedText(selectedWords, container); if (text.length > 250) { - alert('Selected text is too long!!!'); + void showError(t('common.dialog.selection_too_long')); clearSelection(); return; } diff --git a/src/frontend/js/modules/text/pages/reading/text_reading_init.ts b/src/frontend/js/modules/text/pages/reading/text_reading_init.ts index 7986f4314..544eb09c3 100644 --- a/src/frontend/js/modules/text/pages/reading/text_reading_init.ts +++ b/src/frontend/js/modules/text/pages/reading/text_reading_init.ts @@ -11,6 +11,8 @@ * @since 3.0.0 */ +import { t } from '@shared/i18n/translator'; +import { showError } from '@shared/components/confirm_dialog'; import { onDomReady } from '@shared/utils/dom_ready'; import { getLangFromDict } from '@modules/vocabulary/services/dictionary'; import { @@ -137,7 +139,7 @@ export function initTTS(): void { */ function initReading(): void { if (!('speechSynthesis' in window)) { - alert('Your browser does not support speechSynthesis!'); + void showError(t('common.dialog.no_speech')); return; } if (!text_reader) { @@ -164,7 +166,7 @@ function initReading(): void { export function toggleReading(): void { const synth = window.speechSynthesis; if (synth === undefined) { - alert('Your browser does not support speechSynthesis!'); + void showError(t('common.dialog.no_speech')); return; } if (synth.speaking) { 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 7c484fec1..163b80054 100644 --- a/src/frontend/js/modules/text/pages/texts_grouped_app.ts +++ b/src/frontend/js/modules/text/pages/texts_grouped_app.ts @@ -11,6 +11,8 @@ * @since 3.0.0 */ +import { t } from '@shared/i18n/translator'; +import { showError } from '@shared/components/confirm_dialog'; import Alpine from 'alpinejs'; import { initIcons } from '@shared/icons/lucide_icons'; import { apiGet, getCsrfToken } from '@shared/api/client'; @@ -307,7 +309,7 @@ export function textsGroupedData(): TextsGroupedData { async submitBulkApiAction(action: 'archive' | 'delete', ids: number[]) { const res = await TextsApi.bulkAction(action, ids); if (res.error) { - alert('Action failed. Please try again.'); + void showError(t('common.dialog.action_failed')); return; } window.location.reload(); @@ -327,7 +329,7 @@ export function textsGroupedData(): TextsGroupedData { window.location.reload(); }).catch((error) => { console.error('Delete failed:', error); - alert('Failed to delete. Please try again.'); + void showError(t('common.dialog.delete_failed')); }); } }, 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 7fd33d403..987e1b0af 100644 --- a/src/frontend/js/modules/vocabulary/pages/word_list_app.ts +++ b/src/frontend/js/modules/vocabulary/pages/word_list_app.ts @@ -11,7 +11,7 @@ * @since 3.0.0 */ -import { confirmAction, confirmDeletion } from '@shared/components/confirm_dialog'; +import { confirmAction, confirmDeletion, promptDialog, showAlert, showError } from '@shared/components/confirm_dialog'; import Alpine from 'alpinejs'; import { initIcons } from '@shared/icons/lucide_icons'; import { t, tn } from '@shared/i18n/translator'; @@ -481,7 +481,7 @@ export function wordListData(): WordListData { const ids = this.getMarkedIds(); if (ids.length === 0) { - alert(t('vocabulary.list.no_terms_selected')); + void showAlert(t('vocabulary.list.no_terms_selected')); select.value = ''; return; } @@ -490,16 +490,17 @@ export function wordListData(): WordListData { // Handle actions that need extra data if (action === 'addtag' || action === 'deltag') { - const tag = prompt(t('vocabulary.list.prompt_tag')); + const tag = await promptDialog(t('vocabulary.list.prompt_tag'), { + validate: (value) => ( + value.includes(' ') || value.includes(',') || value.length > 20 + ? t('vocabulary.list.invalid_tag') + : null + ) + }); if (!tag) { select.value = ''; return; } - if (tag.includes(' ') || tag.includes(',') || tag.length > 20) { - alert(t('vocabulary.list.invalid_tag')); - select.value = ''; - return; - } data = tag; } @@ -539,7 +540,7 @@ export function wordListData(): WordListData { this.marked.clear(); await this.loadWords(); } else { - alert(response.data?.message || response.error || t('vocabulary.list.action_failed')); + void showError(response.data?.message || response.error || t('vocabulary.list.action_failed')); } select.value = ''; @@ -574,7 +575,7 @@ export function wordListData(): WordListData { let data: string | undefined; if (action.endsWith('addtag') || action.endsWith('deltag')) { - const tag = prompt(t('vocabulary.list.prompt_tag_simple')); + const tag = await promptDialog(t('vocabulary.list.prompt_tag_simple')); if (!tag) { select.value = ''; return; @@ -590,7 +591,7 @@ export function wordListData(): WordListData { if (response.data?.success) { await this.loadWords(); } else { - alert(response.data?.message || response.error || t('vocabulary.list.action_failed')); + void showError(response.data?.message || response.error || t('vocabulary.list.action_failed')); } select.value = ''; @@ -637,7 +638,7 @@ export function wordListData(): WordListData { } } } else { - alert(response.data?.error || response.error || t('vocabulary.list.save_failed')); + void showError(response.data?.error || response.error || t('vocabulary.list.save_failed')); } this.editingWord = null; diff --git a/src/frontend/js/modules/vocabulary/services/term_operations.ts b/src/frontend/js/modules/vocabulary/services/term_operations.ts index 671d2d063..70a7a2726 100644 --- a/src/frontend/js/modules/vocabulary/services/term_operations.ts +++ b/src/frontend/js/modules/vocabulary/services/term_operations.ts @@ -6,6 +6,8 @@ * @since 1.6.16-fork */ +import { t } from '@shared/i18n/translator'; +import { showError } from '@shared/components/confirm_dialog'; import { onDomReady } from '@shared/utils/dom_ready'; import { escapeHtml } from '@shared/utils/html_utils'; import { isInt } from '@shared/forms/form_validation'; @@ -99,10 +101,9 @@ export async function saveImprovedTextAnnotation(textid: number, elem_name: stri waitEl.innerHTML = iconHtml('empty'); } if (response.error || response.data?.error) { - alert( - 'Saving your changes failed, please reload the page and try again! ' + - 'Error message: "' + (response.error || response.data?.error) + '".' - ); + void showError(t('common.dialog.term_save_failed', { + error: String(response.error || response.data?.error) + })); } } @@ -144,16 +145,15 @@ export async function updateTermTranslation(wordid: number, txid: string): Promi const translation = (el?.value || '').trim(); const pagepos = window.scrollY || document.documentElement.scrollTop || 0; if (translation === '' || translation === '*') { - alert('Text Field is empty or = \'*\'!'); + void showError(t('common.dialog.text_field_empty')); return; } - const failure = 'Updating translation of term failed!' + - 'Please reload page and try again.'; + const failure = t('common.dialog.translation_update_failed'); const response = await TermsApi.updateTranslation(wordid, translation); if (response.error) { - alert(failure + '\n' + response.error); + void showError(failure + '\n' + response.error); return; } if (response.data?.update) { @@ -173,16 +173,15 @@ export async function addTermTranslation(txid: string, word: string, lang: numbe const translation = (el?.value || '').trim(); const pagepos = window.scrollY || document.documentElement.scrollTop || 0; if (translation === '' || translation === '*') { - alert('Text Field is empty or = \'*\'!'); + void showError(t('common.dialog.text_field_empty')); return; } - const failure = 'Adding translation to term failed!' + - 'Please reload page and try again.'; + const failure = t('common.dialog.translation_add_failed'); const response = await TermsApi.addWithTranslation(word, lang, translation); if (response.error) { - alert(failure + '\n' + response.error); + void showError(failure + '\n' + response.error); return; } if (response.data?.add && response.data?.term_id !== undefined) { @@ -341,7 +340,7 @@ export async function loadTermTranslations(pagepos: number, word: string, term_i ); if (response.error || response.data?.error) { - alert(response.error || response.data?.error); + void showError(String(response.error || response.data?.error)); } else if (response.data) { editTermAnnotationTranslations(response.data, textid); scrollTo(pagepos); diff --git a/src/frontend/js/modules/vocabulary/services/translation_api.ts b/src/frontend/js/modules/vocabulary/services/translation_api.ts index 128240b4a..9be8e326e 100644 --- a/src/frontend/js/modules/vocabulary/services/translation_api.ts +++ b/src/frontend/js/modules/vocabulary/services/translation_api.ts @@ -7,7 +7,7 @@ */ import { t } from '@shared/i18n/translator'; -import { confirmAction } from '@shared/components/confirm_dialog'; +import { confirmAction, showError } from '@shared/components/confirm_dialog'; import { iconHtml } from '@shared/icons/icons'; import { escapeHtml } from '@shared/utils/html_utils'; @@ -73,12 +73,12 @@ export async function addTranslation(s: string): Promise { frame = window.opener as LwtFrame | undefined; } if (frame === undefined) { - alert('Translation can not be copied!'); + void showError(t('common.dialog.translation_not_copied')); return; } const word_trans = (frame.document.forms[0] as HTMLFormElement & { WoTranslation: HTMLInputElement }).WoTranslation; if (typeof word_trans !== 'object') { - alert('Translation can not be copied!'); + void showError(t('common.dialog.translation_not_copied')); return; } const oldValue = word_trans.value; diff --git a/src/frontend/js/shared/components/confirm_dialog.ts b/src/frontend/js/shared/components/confirm_dialog.ts index 05ba1dbb5..cf9b7fc78 100644 --- a/src/frontend/js/shared/components/confirm_dialog.ts +++ b/src/frontend/js/shared/components/confirm_dialog.ts @@ -1,17 +1,20 @@ /** - * In-app confirmation dialog. + * In-app dialogs: confirmation, alert and text entry. * - * Use this rather than `window.confirm()`, which stops the whole page, ignores - * the theme and shows a browser-chosen title. The native dialog remains only - * as a fallback for when this one cannot be built. + * Use these rather than `confirm()`, `alert()` and `prompt()`, which stop the + * whole page, ignore the theme and show a browser-chosen title. The native + * dialogs remain only as a fallback for when these cannot be built. + * + * One dialog shows at a time, and the others wait their turn. * * 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. + * Choosing a button, pressing Escape or clicking outside settles the promise. + * Everything except the confirm button counts as declining, and in a + * confirmation the decline button has the focus, so a stray Enter loses + * nothing. * * @license Unlicense * @since 3.8.1 @@ -32,21 +35,51 @@ export interface ConfirmDialogOptions { danger?: boolean; } -/** The dialog on screen, so a second request joins it rather than stacking. */ +/** What a dialog shows. */ +interface DialogView { + title?: string; + message: string; + confirmLabel: string; + /** Without one the dialog only acknowledges, like `alert()`. */ + cancelLabel?: string; + danger?: boolean; + /** Adds a text field, like `prompt()`. */ + input?: { + value: string; + /** Returns what is wrong with the value, or null if it is fine. */ + validate?: (value: string) => string | null; + }; +} + +/** How the user left a dialog. */ +interface DialogOutcome { + confirmed: boolean; + value: string; +} + +/** A dialog is on screen. */ +let showing = false; + +/** Dialogs waiting for the one on screen to close. */ +const waiting: Array<() => void> = []; + +/** The confirmation on screen, so a second request joins it. */ let pending: Promise | null = null; /** - * Build a Bulma modal with the given text. + * Build a Bulma modal for a view. * - * @param options Dialog text + * @param view Dialog content * - * @returns The root element and its two buttons + * @returns The root element and the parts that take part in the interaction */ -function buildDialog(options: ConfirmDialogOptions): { +function buildDialog(view: DialogView): { root: HTMLElement; - confirmButton: HTMLButtonElement; - cancelButton: HTMLButtonElement; background: HTMLElement; + confirmButton: HTMLButtonElement; + cancelButton: HTMLButtonElement | null; + input: HTMLInputElement | null; + error: HTMLElement | null; } { const root = document.createElement('div'); root.className = 'modal is-active confirm-dialog'; @@ -63,38 +96,173 @@ function buildDialog(options: ConfirmDialogOptions): { const body = document.createElement('section'); body.className = 'modal-card-body'; - if (options.title) { + if (view.title) { const title = document.createElement('p'); title.id = 'confirm-dialog-title'; title.className = 'title is-5 mb-2'; - title.textContent = options.title; + title.textContent = view.title; root.setAttribute('aria-labelledby', title.id); body.append(title); } const message = document.createElement('p'); message.id = 'confirm-dialog-message'; - message.textContent = options.message; + message.textContent = view.message; + body.append(message); + + let input: HTMLInputElement | null = null; + let error: HTMLElement | null = null; + if (view.input) { + input = document.createElement('input'); + input.type = 'text'; + input.className = 'input mt-3'; + input.value = view.input.value; + input.setAttribute('aria-labelledby', 'confirm-dialog-message'); + error = document.createElement('p'); + error.className = 'help is-danger'; + error.setAttribute('role', 'alert'); + body.append(input, error); + } const footer = document.createElement('footer'); footer.className = 'modal-card-foot confirm-dialog__actions'; - const cancelButton = document.createElement('button'); - cancelButton.type = 'button'; - cancelButton.className = 'button'; - cancelButton.textContent = options.cancelLabel; + let cancelButton: HTMLButtonElement | null = null; + if (view.cancelLabel) { + cancelButton = document.createElement('button'); + cancelButton.type = 'button'; + cancelButton.className = 'button'; + cancelButton.textContent = view.cancelLabel; + footer.append(cancelButton); + } const confirmButton = document.createElement('button'); confirmButton.type = 'button'; - confirmButton.className = options.danger ? 'button is-danger' : 'button is-primary'; - confirmButton.textContent = options.confirmLabel; + confirmButton.className = view.danger ? 'button is-danger' : 'button is-primary'; + confirmButton.textContent = view.confirmLabel; + footer.append(confirmButton); - body.append(message); - footer.append(cancelButton, confirmButton); card.append(body, footer); root.append(background, card); - return { root, confirmButton, cancelButton, background }; + return { root, background, confirmButton, cancelButton, input, error }; +} + +/** + * Put a dialog on screen. + * + * @param view Dialog content + * @param done Called once, with how the user left it + */ +function renderDialog(view: DialogView, done: (outcome: DialogOutcome) => void): void { + const dialog = buildDialog(view); + document.body.append(dialog.root); + + const { root, background, confirmButton, cancelButton, input, error } = dialog; + const previouslyFocused = document.activeElement as HTMLElement | null; + const focusable = [input, cancelButton, confirmButton] + .filter((el): el is HTMLInputElement | HTMLButtonElement => el !== null); + + const settle = (confirmed: boolean): void => { + document.removeEventListener('keydown', onKeydown, true); + root.remove(); + previouslyFocused?.focus?.(); + done({ confirmed, value: input?.value ?? '' }); + }; + + const submit = (): void => { + const problem = input ? view.input?.validate?.(input.value) : null; + if (problem && error) { + error.textContent = problem; + input?.focus(); + return; + } + settle(true); + }; + + // Capturing, and stopped here: the dialogs underneath also listen for + // Escape on the document, and would close behind this one. + const onKeydown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopImmediatePropagation(); + settle(false); + } else if (event.key === 'Tab') { + event.preventDefault(); + event.stopImmediatePropagation(); + const index = focusable.indexOf(document.activeElement as HTMLButtonElement); + focusable[(index + 1) % focusable.length].focus(); + } else if (event.key === 'Enter' && event.target === input) { + event.preventDefault(); + event.stopImmediatePropagation(); + submit(); + } + }; + + document.addEventListener('keydown', onKeydown, true); + confirmButton.addEventListener('click', submit); + cancelButton?.addEventListener('click', () => settle(false)); + background.addEventListener('click', () => settle(false)); + + // Opened by pressing Enter, this dialog takes the focus before the browser + // has finished with the key, and the same press would activate its button. + const swallowKeypress = (event: Event): void => event.preventDefault(); + document.addEventListener('keypress', swallowKeypress, { capture: true, once: true }); + setTimeout(() => document.removeEventListener('keypress', swallowKeypress, true), 0); + + (input ?? cancelButton ?? confirmButton).focus(); + input?.select(); +} + +/** + * Ask through the browser, for when the dialog cannot be built. + * + * @param view Dialog content + * + * @returns How the user answered + */ +function askNatively(view: DialogView): DialogOutcome { + const text = view.title ? `${view.title}\n\n${view.message}` : view.message; + if (view.input) { + const value = window.prompt(text, view.input.value); + return { confirmed: value !== null, value: value ?? '' }; + } + if (view.cancelLabel) { + return { confirmed: window.confirm(text), value: '' }; + } + window.alert(text); + return { confirmed: true, value: '' }; +} + +/** + * Show a dialog, after any that is already on screen. + * + * @param view Dialog content + * + * @returns Promise resolving to how the user left it + */ +function openDialog(view: DialogView): Promise { + return new Promise((resolve) => { + const show = (): void => { + showing = true; + const done = (outcome: DialogOutcome): void => { + showing = false; + resolve(outcome); + waiting.shift()?.(); + }; + try { + renderDialog(view, done); + } catch { + done(askNatively(view)); + } + }; + + if (showing) { + waiting.push(show); + } else { + show(); + } + }); } /** @@ -107,49 +275,64 @@ function buildDialog(options: ConfirmDialogOptions): { export function confirmDialog(options: ConfirmDialogOptions): Promise { if (pending) return pending; - let dialog: ReturnType; - try { - dialog = buildDialog(options); - document.body.append(dialog.root); - } catch { - const text = options.title ? `${options.title}\n\n${options.message}` : options.message; - return Promise.resolve(window.confirm(text)); - } - - const { root, confirmButton, cancelButton, background } = dialog; - const previouslyFocused = document.activeElement as HTMLElement | null; + pending = openDialog(options).then(({ confirmed }) => { + pending = null; + return confirmed; + }); + return pending; +} - pending = new Promise((resolve) => { - const settle = (confirmed: boolean): void => { - document.removeEventListener('keydown', onKeydown, true); - root.remove(); - pending = null; - previouslyFocused?.focus?.(); - resolve(confirmed); - }; +/** + * Tell the user something, in place of `alert()`. + * + * The code after this call runs straight away. Wait for the promise only for + * what must happen once the user has read it, such as moving the focus. + * + * @param message What to say + * @param title Heading, if any + * + * @returns Promise resolving once the user has dismissed it + */ +export async function showAlert(message: string, title?: string): Promise { + await openDialog({ title, message, confirmLabel: t('common.dialog.ok') }); +} - // Capturing, and stopped here: the dialogs underneath also listen for - // Escape on the document, and would close behind this one. - const onKeydown = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { - event.preventDefault(); - event.stopImmediatePropagation(); - settle(false); - } else if (event.key === 'Tab') { - event.preventDefault(); - event.stopImmediatePropagation(); - (document.activeElement === cancelButton ? confirmButton : cancelButton).focus(); - } - }; +/** + * Report a problem to the user. + * + * @param message What went wrong + * + * @returns Promise resolving once the user has dismissed it + */ +export function showError(message: string): Promise { + return showAlert(message, t('common.dialog.error')); +} - document.addEventListener('keydown', onKeydown, true); - confirmButton.addEventListener('click', () => settle(true)); - cancelButton.addEventListener('click', () => settle(false)); - background.addEventListener('click', () => settle(false)); +/** + * Ask the user for some text, in place of `prompt()`. + * + * @param message What to enter + * @param options Heading, starting value, and a check that keeps the dialog + * open and shows what is wrong + * + * @returns Promise resolving to the text, or null if the user cancelled + */ +export async function promptDialog( + message: string, + options: { + title?: string; + value?: string; + validate?: (value: string) => string | null; + } = {} +): Promise { + const { confirmed, value } = await openDialog({ + title: options.title, + message, + confirmLabel: t('common.dialog.ok'), + cancelLabel: t('common.cancel'), + input: { value: options.value ?? '', validate: options.validate } }); - - cancelButton.focus(); - return pending; + return confirmed ? value : null; } /** diff --git a/src/frontend/js/shared/components/inline_edit.ts b/src/frontend/js/shared/components/inline_edit.ts index 544dcd30f..3e4d8f1dc 100644 --- a/src/frontend/js/shared/components/inline_edit.ts +++ b/src/frontend/js/shared/components/inline_edit.ts @@ -8,6 +8,8 @@ * @since 3.0.0 */ +import { t } from '@shared/i18n/translator'; +import { showError } from '@shared/components/confirm_dialog'; import { spinnerHtml } from '../icons/icons'; import { getCsrfToken } from '@shared/api/client'; @@ -237,7 +239,7 @@ async function saveEdit( cancelEdit(edit); }); - alert('Error saving changes. Please try again.'); + void showError(t('common.dialog.save_failed')); } } diff --git a/src/frontend/js/shared/forms/bulk_actions.ts b/src/frontend/js/shared/forms/bulk_actions.ts index c2d3409f4..b249333b3 100644 --- a/src/frontend/js/shared/forms/bulk_actions.ts +++ b/src/frontend/js/shared/forms/bulk_actions.ts @@ -8,7 +8,7 @@ * @since 2.10.0-fork Extracted from pgm.ts */ -import { confirmAction } from '@shared/components/confirm_dialog'; +import { confirmAction, promptDialog } from '@shared/components/confirm_dialog'; import { t as translate } from '@shared/i18n/translator'; import { markClick } from '@shared/utils/ui_utilities'; @@ -29,40 +29,39 @@ interface FormWithData extends HTMLFormElement { data?: HTMLInputElement; } +/** + * Ask for a tag, then submit the form with it. + * + * @param f The form + * @param context What the action will affect + * @param title The action's name + */ +function submitWithTag(f: HTMLFormElement | FormWithData, context: string, title: string): void { + void promptDialog(`${context}\n\n${translate('common.dialog.enter_tag')}`, { + title, + validate: (value) => ( + value.includes(' ') || value.includes(',') || value.length > 20 + ? translate('common.dialog.tag_invalid') + : null + ) + }).then((tag) => { + if (!tag) return; + const formWithData = f as FormWithData; + if (formWithData.data) { + formWithData.data.value = tag; + } + f.submit(); + }); +} + export function multiActionGo(f: HTMLFormElement | FormWithData | undefined, sel: HTMLSelectElement | undefined): void { if (f !== undefined && sel !== undefined) { const v = sel.value; const t = sel.options[sel.selectedIndex].text; if (typeof v === 'string') { if (v === 'addtag' || v === 'deltag') { - let notok = true; - let answer: string | null = ''; const checkedCount = document.querySelectorAll('input.markcheck:checked').length; - while (notok) { - answer = prompt( - '*** ' + t + ' ***' + - '\n\n*** ' + checkedCount + - ' Record(s) will be affected ***' + - '\n\nPlease enter one tag (20 char. max., no spaces, no commas -- ' + - 'or leave empty to cancel:', - answer || '' - ); - if (answer === null) answer = ''; - if (answer.indexOf(' ') > 0 || answer.indexOf(',') > 0) { - alert('Please no spaces or commas!'); - } else if (answer.length > 20) { - alert('Please no tags longer than 20 char.!'); - } else { - notok = false; - } - } - if (answer !== '') { - const formWithData = f as FormWithData; - if (formWithData.data) { - formWithData.data.value = answer; - } - f.submit(); - } + submitWithTag(f, translate('common.dialog.records_affected', { count: checkedCount }), t); } else if ( v === 'del' || v === 'smi1' || v === 'spl1' || v === 's1' || v === 's5' || v === 's98' || v === 's99' || v === 'today' || v === 'delsent' || @@ -91,33 +90,9 @@ export function allActionGo(f: HTMLFormElement | FormWithData | undefined, sel: const t = sel.options[sel.selectedIndex].text; if (typeof v === 'string') { if (v === 'addtagall' || v === 'deltagall') { - let notok = true; - let answer: string | null = ''; - while (notok) { - answer = prompt( - 'THIS IS AN ACTION ON ALL RECORDS\n' + - 'ON ALL PAGES OF THE CURRENT QUERY!\n\n' + - '*** ' + t + ' ***\n\n*** ' + n + ' Record(s) will be affected ***\n\n' + - 'Please enter one tag (20 char. max., no spaces, no commas -- ' + - 'or leave empty to cancel:', - answer || '' - ); - if (answer === null) answer = ''; - if (answer.indexOf(' ') > 0 || answer.indexOf(',') > 0) { - alert('Please no spaces or commas!'); - } else if (answer.length > 20) { - alert('Please no tags longer than 20 char.!'); - } else { - notok = false; - } - } - if (answer !== '') { - const formWithData = f as FormWithData; - if (formWithData.data) { - formWithData.data.value = answer; - } - f.submit(); - } + const warning = translate('common.dialog.all_records_warning'); + const affected = translate('common.dialog.records_affected', { count: n }); + submitWithTag(f, `${warning}\n\n${affected}`, t); } else if ( v === 'delall' || v === 'smi1all' || v === 'spl1all' || v === 's1all' || v === 's5all' || v === 's98all' || v === 's99all' || v === 'todayall' || diff --git a/src/frontend/js/shared/forms/form_validation.ts b/src/frontend/js/shared/forms/form_validation.ts index 63ff859d5..c194db09b 100644 --- a/src/frontend/js/shared/forms/form_validation.ts +++ b/src/frontend/js/shared/forms/form_validation.ts @@ -6,6 +6,9 @@ * @since 1.6.16-fork */ +import { showError } from '@shared/components/confirm_dialog'; +import { t } from '@shared/i18n/translator'; + /** * Helper to safely get an HTML attribute value as a string. * @@ -50,15 +53,11 @@ export function alertFirstCharacterOutsideBasicMultilingualPlane(s: string, info } const match = /[\uD800-\uDFFF]/.exec(s); if (!match) return 0; - alert( - 'ERROR\n\nText "' + info + '" contains invalid character(s) ' + - '(in the Unicode Supplementary Multilingual Planes, > U+FFFF) like emojis ' + - 'or very rare characters.\n\nFirst invalid character: "' + - s.substring(match.index, match.index + 2) + '" at position ' + - (match.index + 1) + '.\n\n' + - 'More info: https://en.wikipedia.org/wiki/Plane_(Unicode)\n\n' + - 'Please remove this/these character(s) and try again.' - ); + void showError(t('common.dialog.field_unsupported_char', { + field: info, + char: s.substring(match.index, match.index + 2), + position: match.index + 1 + })); return 1; } @@ -114,7 +113,7 @@ export function check(): boolean { if (el.value.trim() === '') count++; }); if (count > 0) { - alert('ERROR\n\n' + count + ' field(s) - marked with * - must not be empty!'); + void showError(t('common.dialog.fields_empty', { count })); return false; } @@ -127,10 +126,7 @@ export function check(): boolean { if ((val.indexOf('http://') !== 0) && (val.indexOf('https://') !== 0) && (val.indexOf('#') !== 0)) { - alert( - 'ERROR\n\nField "' + el.getAttribute('data_info') + - '" must start with "http://" or "https://" if not empty.' - ); + void showError(t('common.dialog.field_url', { field: el.getAttribute('data_info') ?? '' })); count++; } } @@ -146,7 +142,7 @@ export function check(): boolean { xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.send('action=&action_type=check_regexp®ex=' + encodeURIComponent(regexp)); if (xhr.responseText !== '') { - alert(xhr.responseText); + void showError(xhr.responseText); count++; } } @@ -160,10 +156,10 @@ export function check(): boolean { const val = el.value.trim(); if (val.length > 0) { if (parseInt(val, 10) > maxvalue) { - alert( - 'ERROR\n\n Max Value of Field "' + el.getAttribute('data_info') + - '" is ' + maxvalue - ); + void showError(t('common.dialog.field_max', { + field: el.getAttribute('data_info') ?? '', + max: maxvalue + })); count++; } } @@ -184,10 +180,7 @@ export function check(): boolean { new URL(refinned); } catch (err) { if (err instanceof TypeError) { - alert( - 'ERROR\n\nField "' + el.getAttribute('data_info') + - '" should be an URL if not empty.' - ); + void showError(t('common.dialog.field_dict_url', { field: el.getAttribute('data_info') ?? '' })); count++; } } @@ -199,10 +192,7 @@ export function check(): boolean { const val = el.value.trim(); if (val.length > 0) { if (!(isInt(val) && (parseInt(val, 10) > 0))) { - alert( - 'ERROR\n\nField "' + el.getAttribute('data_info') + - '" must be an integer number > 0.' - ); + void showError(t('common.dialog.field_positive', { field: el.getAttribute('data_info') ?? '' })); count++; } } @@ -213,10 +203,7 @@ export function check(): boolean { const val = el.value.trim(); if (val.length > 0) { if (!(isInt(val) && (parseInt(val, 10) >= 0))) { - alert( - 'ERROR\n\nField "' + el.getAttribute('data_info') + - '" must be an integer number >= 0.' - ); + void showError(t('common.dialog.field_nonnegative', { field: el.getAttribute('data_info') ?? '' })); count++; } } @@ -239,11 +226,10 @@ export function check(): boolean { const maxLength = parseInt(getAttr(el, 'data_maxlength') || '0', 10); const val = getVal(el); if (val.trim().length > maxLength) { - alert( - 'ERROR\n\nText is too long in field "' + getAttr(el, 'data_info') + - '", please make it shorter! (Maximum length: ' + - getAttr(el, 'data_maxlength') + ' char.)' - ); + void showError(t('common.dialog.field_too_long_chars', { + field: getAttr(el, 'data_info'), + max: getAttr(el, 'data_maxlength') + })); count++; } }); @@ -263,11 +249,10 @@ export function check(): boolean { const maxLength = parseInt(getAttr(el, 'data_maxlength') || '0', 10); const val = getVal(el); if (getUTF8Length(val.trim()) > maxLength) { - alert( - 'ERROR\n\nText is too long in field "' + getAttr(el, 'data_info') + - '", please make it shorter! (Maximum length: ' + - getAttr(el, 'data_maxlength') + ' bytes.)' - ); + void showError(t('common.dialog.field_too_long_bytes', { + field: getAttr(el, 'data_info'), + max: getAttr(el, 'data_maxlength') + })); count++; } }); @@ -276,10 +261,7 @@ export function check(): boolean { document.querySelectorAll('input.noblanksnocomma').forEach(el => { const val = el.value; if (val.indexOf(' ') > 0 || val.indexOf(',') > 0) { - alert( - 'ERROR\n\nNo spaces or commas allowed in field "' + - el.getAttribute('data_info') + '", please remove!' - ); + void showError(t('common.dialog.field_no_spaces', { field: el.getAttribute('data_info') ?? '' })); count++; } }); diff --git a/src/frontend/js/shared/utils/simple_interactions.ts b/src/frontend/js/shared/utils/simple_interactions.ts index 7208ef3b9..4c510370c 100644 --- a/src/frontend/js/shared/utils/simple_interactions.ts +++ b/src/frontend/js/shared/utils/simple_interactions.ts @@ -14,7 +14,7 @@ */ import { t } from '@shared/i18n/translator'; -import { confirmAction, confirmBeforeSubmit, confirmDeletion } from '@shared/components/confirm_dialog'; +import { confirmAction, confirmBeforeSubmit, confirmDeletion, showError } from '@shared/components/confirm_dialog'; import { lwtFormCheck } from '@shared/forms/unloadformcheck'; import { getCsrfToken } from '@shared/api/client'; import { showAllwordsClick } from './ui_utilities'; @@ -266,7 +266,7 @@ export function initSimpleInteractions(): void { }) .catch(err => { console.error('Failed to mark all as well-known:', err); - alert('Failed to mark words. Please try again.'); + void showError(t('common.dialog.mark_failed')); }) .finally(() => { el.classList.remove('is-loading'); @@ -293,7 +293,7 @@ export function initSimpleInteractions(): void { }) .catch(err => { console.error('Failed to mark all as ignored:', err); - alert('Failed to mark words. Please try again.'); + void showError(t('common.dialog.mark_failed')); }) .finally(() => { el.classList.remove('is-loading'); diff --git a/src/frontend/js/shared/utils/ui_utilities.ts b/src/frontend/js/shared/utils/ui_utilities.ts index 0ac921fea..4fc0b9500 100644 --- a/src/frontend/js/shared/utils/ui_utilities.ts +++ b/src/frontend/js/shared/utils/ui_utilities.ts @@ -6,7 +6,8 @@ * @since 1.6.16-fork */ -import { confirmDeletion } from '@shared/components/confirm_dialog'; +import { t } from '@shared/i18n/translator'; +import { confirmDeletion, showError } 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'; @@ -81,10 +82,12 @@ async function handleConfirmDelete(event: Event): Promise { // Reload page to show updated list window.location.reload(); } else { - alert('Delete failed: ' + response.statusText); + void showError(t('common.dialog.delete_failed_reason', { reason: response.statusText })); } } catch (error) { - alert('Delete failed: ' + (error instanceof Error ? error.message : 'Unknown error')); + void showError(t('common.dialog.delete_failed_reason', { + reason: error instanceof Error ? error.message : String(error) + })); } } else if (href) { @@ -136,7 +139,7 @@ export async function showAllwordsClick(): Promise { window.location.reload(); } catch (err) { console.error('Failed to save display settings:', err); - alert('Failed to save settings. Please try again.'); + void showError(t('common.dialog.save_failed')); // Revert checkbox states on error if (showAllEl) showAllEl.checked = showAll !== '1'; if (showLearningEl) showLearningEl.checked = showLearning !== '1'; diff --git a/tests/frontend/core/language_settings.test.ts b/tests/frontend/core/language_settings.test.ts index f33a57893..9dc3dc200 100644 --- a/tests/frontend/core/language_settings.test.ts +++ b/tests/frontend/core/language_settings.test.ts @@ -1,7 +1,7 @@ /** * Tests for core/language_settings.ts - Language settings utilities */ -import { answerConfirms, settle } from '../helpers/confirm_answers'; +import { answerConfirms, dismissAlerts, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; // Mock dependencies @@ -339,58 +339,64 @@ describe('core/language_settings.ts', () => { expect(result).toBe(true); }); - it('returns false for empty prefix', () => { - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + it('returns false for empty prefix', async () => { + const alertSpy = dismissAlerts(); const result = validateTablePrefix(''); expect(result).toBe(false); + await settle(); expect(alertSpy).toHaveBeenCalled(); }); - it('returns false for prefix longer than 20 characters', () => { - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + it('returns false for prefix longer than 20 characters', async () => { + const alertSpy = dismissAlerts(); const result = validateTablePrefix('a'.repeat(21)); expect(result).toBe(false); + await settle(); expect(alertSpy).toHaveBeenCalled(); }); - it('returns false for prefix with special characters', () => { - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + it('returns false for prefix with special characters', async () => { + const alertSpy = dismissAlerts(); const result = validateTablePrefix('my-prefix'); expect(result).toBe(false); + await settle(); expect(alertSpy).toHaveBeenCalled(); }); - it('returns false for prefix with spaces', () => { - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + it('returns false for prefix with spaces', async () => { + const alertSpy = dismissAlerts(); const result = validateTablePrefix('my prefix'); expect(result).toBe(false); + await settle(); expect(alertSpy).toHaveBeenCalled(); }); - it('returns false for prefix with dots', () => { - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + it('returns false for prefix with dots', async () => { + const alertSpy = dismissAlerts(); const result = validateTablePrefix('my.prefix'); expect(result).toBe(false); + await settle(); expect(alertSpy).toHaveBeenCalled(); }); - it('shows error message for invalid prefix', () => { - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + it('shows error message for invalid prefix', async () => { + const alertSpy = dismissAlerts(); validateTablePrefix('invalid!'); + await settle(); expect(alertSpy).toHaveBeenCalledWith( - expect.stringContaining('Table Set Name') + expect.stringContaining('table set name') ); }); @@ -406,12 +412,13 @@ describe('core/language_settings.ts', () => { expect(result).toBe(true); }); - it('returns false for Unicode characters', () => { - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + it('returns false for Unicode characters', async () => { + const alertSpy = dismissAlerts(); const result = validateTablePrefix('日本語'); expect(result).toBe(false); + await settle(); expect(alertSpy).toHaveBeenCalled(); }); @@ -433,11 +440,12 @@ describe('core/language_settings.ts', () => { expect(result).toBe(true); }); - it('does not show alert for valid prefix', () => { - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + it('does not show alert for valid prefix', async () => { + const alertSpy = dismissAlerts(); validateTablePrefix('validprefix'); + await settle(); expect(alertSpy).not.toHaveBeenCalled(); }); }); @@ -514,7 +522,7 @@ describe('core/language_settings.ts', () => { expect(validateTablePrefix(exactLength)).toBe(true); const tooLong = 'a'.repeat(21); - vi.spyOn(window, 'alert').mockImplementation(() => {}); + dismissAlerts(); expect(validateTablePrefix(tooLong)).toBe(false); }); diff --git a/tests/frontend/core/ui_utilities.test.ts b/tests/frontend/core/ui_utilities.test.ts index c80946e44..f7d12e613 100644 --- a/tests/frontend/core/ui_utilities.test.ts +++ b/tests/frontend/core/ui_utilities.test.ts @@ -1,7 +1,7 @@ /** * Tests for ui_utilities.ts - DOM manipulation, tooltips, and form wrapping */ -import { answerConfirms, confirmPrompts, settle } from '../helpers/confirm_answers'; +import { answerConfirms, confirmPrompts, dismissAlerts, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Now import the module @@ -81,6 +81,7 @@ describe('ui_utilities.ts', () => { await settle(); expect(result).toBe(true); + await settle(); expect(confirmPrompts).toHaveBeenCalledWith('Are you sure you want to delete this?'); }); @@ -342,7 +343,7 @@ describe('ui_utilities.ts', () => { it('reverts checkbox states and shows alert on error', async () => { fetchSpy.mockRejectedValue(new Error('Network error')); - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + const alertSpy = dismissAlerts(); document.body.innerHTML = ` @@ -351,15 +352,14 @@ describe('ui_utilities.ts', () => { await showAllwordsClick(); - expect(alertSpy).toHaveBeenCalledWith('Failed to save settings. Please try again.'); + await settle(); + expect(alertSpy).toHaveBeenCalledWith('Failed to save. Please try again.'); // Checkboxes should be reverted (were checked, now should be unchecked) const showAllEl = document.getElementById('showallwords') as HTMLInputElement; const showLearningEl = document.getElementById('showlearningtranslations') as HTMLInputElement; expect(showAllEl.checked).toBe(false); expect(showLearningEl.checked).toBe(false); expect(reloadMock).not.toHaveBeenCalled(); - - alertSpy.mockRestore(); }); }); diff --git a/tests/frontend/core/utilities.test.ts b/tests/frontend/core/utilities.test.ts index f7f7548e0..60d937c0a 100644 --- a/tests/frontend/core/utilities.test.ts +++ b/tests/frontend/core/utilities.test.ts @@ -1,6 +1,7 @@ /** * Tests for core utility functions */ +import { confirmPrompts, dismissAlerts, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { getStatusName, @@ -417,14 +418,15 @@ describe('pgm.ts', () => { // =========================================================================== describe('validateTablePrefix', () => { - let alertSpy: ReturnType; + const alertSpy = confirmPrompts; beforeEach(() => { - alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + dismissAlerts(); }); - it('returns true for valid alphanumeric prefix', () => { + it('returns true for valid alphanumeric prefix', async () => { expect(validateTablePrefix('myprefix')).toBe(true); + await settle(); expect(alertSpy).not.toHaveBeenCalled(); }); @@ -436,13 +438,15 @@ describe('pgm.ts', () => { expect(validateTablePrefix('prefix123')).toBe(true); }); - it('returns false for empty prefix', () => { + it('returns false for empty prefix', async () => { expect(validateTablePrefix('')).toBe(false); + await settle(); expect(alertSpy).toHaveBeenCalled(); }); - it('returns false for prefix longer than 20 characters', () => { + it('returns false for prefix longer than 20 characters', async () => { expect(validateTablePrefix('a'.repeat(21))).toBe(false); + await settle(); expect(alertSpy).toHaveBeenCalled(); }); diff --git a/tests/frontend/forms/bulk_actions.test.ts b/tests/frontend/forms/bulk_actions.test.ts index 5c3505b6c..91fc8420b 100644 --- a/tests/frontend/forms/bulk_actions.test.ts +++ b/tests/frontend/forms/bulk_actions.test.ts @@ -1,7 +1,7 @@ /** * Tests for bulk_actions.ts - Bulk action utilities for LWT */ -import { answerConfirms, settle } from '../helpers/confirm_answers'; +import { answerConfirms, answerPrompts, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { selectToggle, @@ -137,7 +137,7 @@ describe('bulk_actions.ts', () => { expect(select.value).toBe(''); }); - it('prompts for tag when action is addtag', () => { + it('prompts for tag when action is addtag', async () => { document.body.innerHTML = `
@@ -153,16 +153,17 @@ describe('bulk_actions.ts', () => { (form as any).data = form.querySelector('input[name="data"]'); const select = document.querySelector('select') as HTMLSelectElement; const submitSpy = vi.spyOn(form, 'submit').mockImplementation(() => {}); - const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('mytag'); + const promptSpy = answerPrompts('mytag'); multiActionGo(form, select); + await settle(); expect(promptSpy).toHaveBeenCalled(); expect(submitSpy).toHaveBeenCalled(); expect((form as any).data.value).toBe('mytag'); }); - it('prompts for tag when action is deltag', () => { + it('prompts for tag when action is deltag', async () => { document.body.innerHTML = ` @@ -177,14 +178,15 @@ describe('bulk_actions.ts', () => { (form as any).data = form.querySelector('input[name="data"]'); const select = document.querySelector('select') as HTMLSelectElement; const submitSpy = vi.spyOn(form, 'submit').mockImplementation(() => {}); - vi.spyOn(window, 'prompt').mockReturnValue('removetag'); + answerPrompts('removetag'); multiActionGo(form, select); + await settle(); expect(submitSpy).toHaveBeenCalled(); }); - it('does not submit when tag prompt is cancelled', () => { + it('does not submit when tag prompt is cancelled', async () => { document.body.innerHTML = ` @@ -198,14 +200,15 @@ 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, 'prompt').mockReturnValue(''); + answerPrompts(''); multiActionGo(form, select); + await settle(); expect(submitSpy).not.toHaveBeenCalled(); }); - it('re-prompts when tag contains spaces', () => { + it('re-prompts when tag contains spaces', async () => { document.body.innerHTML = ` @@ -220,18 +223,15 @@ describe('bulk_actions.ts', () => { (form as any).data = form.querySelector('input[name="data"]'); const select = document.querySelector('select') as HTMLSelectElement; vi.spyOn(form, 'submit').mockImplementation(() => {}); - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); - const promptSpy = vi.spyOn(window, 'prompt') - .mockReturnValueOnce('tag with space') - .mockReturnValueOnce('validtag'); + const alertSpy = answerPrompts('tag with space', 'validtag'); multiActionGo(form, select); - expect(alertSpy).toHaveBeenCalledWith('Please no spaces or commas!'); - expect(promptSpy).toHaveBeenCalledTimes(2); + await settle(); + expect(alertSpy).toHaveBeenCalledWith('A tag can have 20 characters at most, with no spaces or commas.'); }); - it('re-prompts when tag contains commas', () => { + it('re-prompts when tag contains commas', async () => { document.body.innerHTML = ` @@ -246,17 +246,15 @@ describe('bulk_actions.ts', () => { (form as any).data = form.querySelector('input[name="data"]'); const select = document.querySelector('select') as HTMLSelectElement; vi.spyOn(form, 'submit').mockImplementation(() => {}); - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); - vi.spyOn(window, 'prompt') - .mockReturnValueOnce('tag,comma') - .mockReturnValueOnce('validtag'); + const alertSpy = answerPrompts('tag,comma', 'validtag'); multiActionGo(form, select); - expect(alertSpy).toHaveBeenCalledWith('Please no spaces or commas!'); + await settle(); + expect(alertSpy).toHaveBeenCalledWith('A tag can have 20 characters at most, with no spaces or commas.'); }); - it('re-prompts when tag is too long', () => { + it('re-prompts when tag is too long', async () => { document.body.innerHTML = ` @@ -271,14 +269,12 @@ describe('bulk_actions.ts', () => { (form as any).data = form.querySelector('input[name="data"]'); const select = document.querySelector('select') as HTMLSelectElement; vi.spyOn(form, 'submit').mockImplementation(() => {}); - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); - vi.spyOn(window, 'prompt') - .mockReturnValueOnce('thistagiswaytoolongmorethan20chars') - .mockReturnValueOnce('shorttag'); + const alertSpy = answerPrompts('thistagiswaytoolongmorethan20chars', 'shorttag'); multiActionGo(form, select); - expect(alertSpy).toHaveBeenCalledWith('Please no tags longer than 20 char.!'); + await settle(); + expect(alertSpy).toHaveBeenCalledWith('A tag can have 20 characters at most, with no spaces or commas.'); }); it('confirms for del action', async () => { @@ -385,7 +381,7 @@ describe('bulk_actions.ts', () => { expect(() => allActionGo(form, undefined, 10)).not.toThrow(); }); - it('prompts for tag when action is addtagall', () => { + it('prompts for tag when action is addtagall', async () => { document.body.innerHTML = ` @@ -399,16 +395,17 @@ describe('bulk_actions.ts', () => { (form as any).data = form.querySelector('input[name="data"]'); const select = document.querySelector('select') as HTMLSelectElement; const submitSpy = vi.spyOn(form, 'submit').mockImplementation(() => {}); - const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('alltag'); + const promptSpy = answerPrompts('alltag'); allActionGo(form, select, 50); + await settle(); expect(promptSpy).toHaveBeenCalled(); expect(submitSpy).toHaveBeenCalled(); expect((form as any).data.value).toBe('alltag'); }); - it('prompts for tag when action is deltagall', () => { + it('prompts for tag when action is deltagall', async () => { document.body.innerHTML = ` @@ -422,14 +419,15 @@ describe('bulk_actions.ts', () => { (form as any).data = form.querySelector('input[name="data"]'); const select = document.querySelector('select') as HTMLSelectElement; const submitSpy = vi.spyOn(form, 'submit').mockImplementation(() => {}); - vi.spyOn(window, 'prompt').mockReturnValue('removetag'); + answerPrompts('removetag'); allActionGo(form, select, 100); + await settle(); expect(submitSpy).toHaveBeenCalled(); }); - it('shows record count in prompt for all actions', () => { + it('shows record count in prompt for all actions', async () => { document.body.innerHTML = ` @@ -443,13 +441,13 @@ describe('bulk_actions.ts', () => { (form as any).data = form.querySelector('input[name="data"]'); const select = document.querySelector('select') as HTMLSelectElement; vi.spyOn(form, 'submit').mockImplementation(() => {}); - const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('tag'); + const promptSpy = answerPrompts('tag'); allActionGo(form, select, 42); + await settle(); expect(promptSpy).toHaveBeenCalledWith( - expect.stringContaining('42 Record(s)'), - '' + expect.stringContaining('Records affected: 42') ); }); @@ -538,7 +536,7 @@ describe('bulk_actions.ts', () => { expect(select.value).toBe(''); }); - it('validates tag input for allActionGo same as multiActionGo', () => { + it('validates tag input for allActionGo same as multiActionGo', async () => { document.body.innerHTML = ` @@ -552,14 +550,12 @@ describe('bulk_actions.ts', () => { (form as any).data = form.querySelector('input[name="data"]'); const select = document.querySelector('select') as HTMLSelectElement; vi.spyOn(form, 'submit').mockImplementation(() => {}); - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); - vi.spyOn(window, 'prompt') - .mockReturnValueOnce('invalid tag') - .mockReturnValueOnce('validtag'); + const alertSpy = answerPrompts('invalid tag', 'validtag'); allActionGo(form, select, 10); - expect(alertSpy).toHaveBeenCalledWith('Please no spaces or commas!'); + await settle(); + expect(alertSpy).toHaveBeenCalledWith('A tag can have 20 characters at most, with no spaces or commas.'); }); }); }); diff --git a/tests/frontend/forms/form_validation.test.ts b/tests/frontend/forms/form_validation.test.ts index a32b3a2ff..2c10e604d 100644 --- a/tests/frontend/forms/form_validation.test.ts +++ b/tests/frontend/forms/form_validation.test.ts @@ -1,6 +1,7 @@ /** * Tests for form_validation.ts - Input validation and form checking utilities */ +import { confirmPrompts, dismissAlerts, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { containsCharacterOutsideBasicMultilingualPlane, @@ -62,34 +63,38 @@ describe('form_validation.ts', () => { // =========================================================================== describe('alertFirstCharacterOutsideBasicMultilingualPlane', () => { - let alertSpy: ReturnType; + const alertSpy = confirmPrompts; beforeEach(() => { - alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + dismissAlerts(); }); - it('returns 0 and does not alert for valid BMP text', () => { + it('returns 0 and does not alert for valid BMP text', async () => { const result = alertFirstCharacterOutsideBasicMultilingualPlane('Hello', 'Test Field'); expect(result).toBe(0); + await settle(); expect(alertSpy).not.toHaveBeenCalled(); }); - it('returns 1 and alerts for text with emoji', () => { + it('returns 1 and alerts for text with emoji', async () => { const result = alertFirstCharacterOutsideBasicMultilingualPlane('Hello 😀 World', 'Test Field'); expect(result).toBe(1); + await settle(); expect(alertSpy).toHaveBeenCalledTimes(1); expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('Test Field')); - expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('invalid character')); + expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('not supported')); }); - it('includes position information in alert', () => { + it('includes position information in alert', async () => { alertFirstCharacterOutsideBasicMultilingualPlane('AB😀CD', 'Field'); + await settle(); expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('position')); }); - it('returns 0 for empty string', () => { + it('returns 0 for empty string', async () => { const result = alertFirstCharacterOutsideBasicMultilingualPlane('', 'Empty Field'); expect(result).toBe(0); + await settle(); expect(alertSpy).not.toHaveBeenCalled(); }); }); @@ -173,16 +178,17 @@ describe('form_validation.ts', () => { // =========================================================================== describe('check', () => { - let alertSpy: ReturnType; + const alertSpy = confirmPrompts; beforeEach(() => { - alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + dismissAlerts(); }); describe('notempty validation', () => { - it('returns true when no notempty fields exist', () => { + it('returns true when no notempty fields exist', async () => { document.body.innerHTML = ''; expect(check()).toBe(true); + await settle(); expect(alertSpy).not.toHaveBeenCalled(); }); @@ -191,10 +197,11 @@ describe('form_validation.ts', () => { expect(check()).toBe(true); }); - it('returns false when notempty field is empty', () => { + it('returns false when notempty field is empty', async () => { document.body.innerHTML = ''; expect(check()).toBe(false); - expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('must not be empty')); + await settle(); + expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('are empty')); }); it('returns false when notempty field has only whitespace', () => { @@ -202,17 +209,18 @@ describe('form_validation.ts', () => { expect(check()).toBe(false); }); - it('counts multiple empty notempty fields', () => { + it('counts multiple empty notempty fields', async () => { document.body.innerHTML = ` `; check(); - expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('3 field(s)')); + await settle(); + expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('are empty: 3')); }); - it('skips notempty fields hidden under an Alpine x-show ancestor', () => { + it('skips notempty fields hidden under an Alpine x-show ancestor', async () => { // /texts/new: TxText is `notempty` but hidden via x-show when the user // is importing a file. Alpine sets display:none on the wrapper. document.body.innerHTML = ` @@ -222,16 +230,18 @@ describe('form_validation.ts', () => { `; expect(check()).toBe(true); + await settle(); expect(alertSpy).not.toHaveBeenCalled(); }); - it('skips notempty fields with the hidden attribute on an ancestor', () => { + it('skips notempty fields with the hidden attribute on an ancestor', async () => { document.body.innerHTML = ` `; expect(check()).toBe(true); + await settle(); expect(alertSpy).not.toHaveBeenCalled(); }); }); @@ -257,9 +267,10 @@ describe('form_validation.ts', () => { expect(check()).toBe(true); }); - it('returns false for URL without protocol', () => { + it('returns false for URL without protocol', async () => { document.body.innerHTML = ''; expect(check()).toBe(false); + await settle(); expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('http://')); }); }); @@ -275,10 +286,11 @@ describe('form_validation.ts', () => { expect(check()).toBe(true); }); - it('returns false for zero', () => { + it('returns false for zero', async () => { document.body.innerHTML = ''; expect(check()).toBe(false); - expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('> 0')); + await settle(); + expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('greater than 0')); }); it('returns false for negative number', () => { @@ -308,10 +320,11 @@ describe('form_validation.ts', () => { expect(check()).toBe(true); }); - it('returns false for negative number', () => { + it('returns false for negative number', async () => { document.body.innerHTML = ''; expect(check()).toBe(false); - expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('>= 0')); + await settle(); + expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('0 or greater')); }); }); @@ -321,10 +334,11 @@ describe('form_validation.ts', () => { expect(check()).toBe(true); }); - it('returns false for text with spaces', () => { + it('returns false for text with spaces', async () => { document.body.innerHTML = ''; expect(check()).toBe(false); - expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('No spaces or commas')); + await settle(); + expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('must not contain spaces or commas')); }); it('returns false for text with commas', () => { @@ -344,10 +358,11 @@ describe('form_validation.ts', () => { expect(check()).toBe(true); }); - it('returns false for text with emoji', () => { + it('returns false for text with emoji', async () => { document.body.innerHTML = ''; expect(check()).toBe(false); - expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('invalid character')); + await settle(); + expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('not supported')); }); it('returns true for empty field', () => { @@ -362,9 +377,10 @@ describe('form_validation.ts', () => { expect(check()).toBe(true); }); - it('returns false when text exceeds limit', () => { + it('returns false when text exceeds limit', async () => { document.body.innerHTML = ''; expect(check()).toBe(false); + await settle(); expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('too long')); }); }); @@ -375,10 +391,11 @@ describe('form_validation.ts', () => { expect(check()).toBe(true); }); - it('returns false when bytes exceed limit', () => { + it('returns false when bytes exceed limit', async () => { // "日本語" is 9 bytes in UTF-8 document.body.innerHTML = ''; expect(check()).toBe(false); + await settle(); expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('bytes')); }); }); @@ -471,7 +488,7 @@ describe('form_validation.ts', () => { `; - vi.spyOn(window, 'alert').mockImplementation(() => {}); + dismissAlerts(); const clickSpy = vi.fn(); const submitBtn = document.querySelector('input[type="submit"]') as HTMLInputElement; diff --git a/tests/frontend/helpers/confirm_answers.ts b/tests/frontend/helpers/confirm_answers.ts index b653096e6..c616b7052 100644 --- a/tests/frontend/helpers/confirm_answers.ts +++ b/tests/frontend/helpers/confirm_answers.ts @@ -1,5 +1,5 @@ /** - * Answer the real in-app confirmation dialog from a test. + * Answer the real in-app dialogs from a test. * * The dialog answers later, so a test must wait for it: call answerConfirms() * before the action, and `await settle()` after it. @@ -11,6 +11,9 @@ export const confirmPrompts = vi.fn(); let observer: MutationObserver | null = null; +/** What the next text dialogs are given. */ +let promptValues: Array = []; + afterEach(() => { observer?.disconnect(); observer = null; @@ -26,13 +29,31 @@ afterEach(() => { export function answerConfirms(answer: boolean): typeof confirmPrompts { observer?.disconnect(); confirmPrompts.mockClear(); + promptValues = []; 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(); + const input = dialog.querySelector('input'); + if (input) { + // A refused value leaves the dialog open, for the next one. + while (dialog.isConnected) { + const value = promptValues.shift() ?? null; + if (value === null) { + buttons[0].click(); + return; + } + input.value = value; + buttons[1].click(); + const problem = dialog.querySelector('.help.is-danger')?.textContent; + if (dialog.isConnected && problem) confirmPrompts(problem); + } + return; + } + // A lone button is an alert, which only acknowledges. + (buttons[answer ? buttons.length - 1 : 0]).click(); }); observer.observe(document.body, { childList: true }); @@ -73,3 +94,25 @@ export function trackSubmissions(): Event[] { window.addEventListener('submit', submissionListener); return submissions; } + +/** + * Dismiss every alert that appears from now on. + * + * @returns The recorder of what was shown + */ +export function dismissAlerts(): typeof confirmPrompts { + return answerConfirms(true); +} + +/** + * Fill in the text dialogs that appear from now on. + * + * @param values What to enter, in order; null cancels + * + * @returns The recorder of what was asked + */ +export function answerPrompts(...values: Array): typeof confirmPrompts { + const recorder = answerConfirms(true); + promptValues = values; + return recorder; +} diff --git a/tests/frontend/languages/language_form.test.ts b/tests/frontend/languages/language_form.test.ts index cd462fecd..7235e034c 100644 --- a/tests/frontend/languages/language_form.test.ts +++ b/tests/frontend/languages/language_form.test.ts @@ -1,6 +1,7 @@ /** * Tests for language_form.ts - Language form configuration */ +import { confirmPrompts, dismissAlerts, settle } from '../helpers/confirm_answers'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { languageForm, @@ -457,7 +458,7 @@ describe('language_form.ts', () => { describe('checkDuplicateLanguage', () => { beforeEach(() => { - vi.spyOn(window, 'alert').mockImplementation(() => {}); + dismissAlerts(); }); it('returns true when language does not exist', () => { @@ -486,7 +487,7 @@ describe('language_form.ts', () => { expect(result).toBe(true); }); - it('returns false when creating duplicate language', () => { + it('returns false when creating duplicate language', async () => { languageForm.languageId = 0; languageForm.allLanguages = { 'English': 1 }; @@ -497,7 +498,22 @@ describe('language_form.ts', () => { const result = checkDuplicateLanguage(); expect(result).toBe(false); - expect(window.alert).toHaveBeenCalled(); + await settle(); + expect(confirmPrompts).toHaveBeenCalled(); + }); + + it('returns the focus to the name once the error is dismissed', async () => { + languageForm.languageId = 0; + languageForm.allLanguages = { 'English': 1 }; + document.body.innerHTML = ` + + `; + dismissAlerts(); + + checkDuplicateLanguage(); + await settle(); + + expect(document.activeElement).toBe(document.getElementById('LgName')); }); it('accepts custom language ID and languages map', () => { @@ -627,7 +643,7 @@ describe('language_form.ts', () => { it('sets up form submit handler for duplicate check', () => { languageForm.allLanguages = { 'English': 1 }; - vi.spyOn(window, 'alert').mockImplementation(() => {}); + dismissAlerts(); document.body.innerHTML = `