From 42fbfcd11f9f7e6eaa34a30288d080741a36b5e0 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Wed, 19 Aug 2026 22:34:39 -0400 Subject: [PATCH] Translate phone number validation messages and intl-tel-input UI (closes #344) - Make PhoneNumber custom data type validation message translatable via window.alTranslate - Provide localized country names and UI labels for intl-tel-input via Intl.DisplayNames and al_translate.js - Re-initialize phone inputs upon receiving the alTranslationsLoaded event - Add phone validation strings to ALTOOLBOX_JS_STRINGS and provide translations in altoolbox_words.yml - Update test_translation_strings.py to assert phone validation string coverage --- README.md | 2 +- docassemble/ALToolbox/PhoneNumberDataType.py | 5 +- .../questions/phone-number-validation.yml | 2 + .../data/sources/altoolbox_words.yml | 150 +++++++++++++++++- .../data/static/phone-number-validation.js | 91 +++++++++-- .../ALToolbox/test_translation_strings.py | 28 +++- docassemble/ALToolbox/translation_strings.py | 15 +- 7 files changed, 270 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index de28761..4bab654 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Docassemble translates anything that reaches Python through `word()`, but JavaSc running in the browser has no way to reach that catalog, so strings in custom datatypes and small widgets tend to stay hardcoded in English. -ALToolbox adds an endpoint, `/al_translations.json?lang=es`, that returns the `word()` +ALToolbox adds an endpoint, `/al_translations.json?lang=es`, that returns the `word()` catalog for a language, and a script that puts it behind `_()`: ```yaml diff --git a/docassemble/ALToolbox/PhoneNumberDataType.py b/docassemble/ALToolbox/PhoneNumberDataType.py index 862b077..9686622 100644 --- a/docassemble/ALToolbox/PhoneNumberDataType.py +++ b/docassemble/ALToolbox/PhoneNumberDataType.py @@ -78,10 +78,13 @@ class PhoneNumber(CustomDataType): * @returns {string} - Message to show if the field is invalid */ function showInvalidInputMessage( params, field ) { + var defaultMessage = `This phone number doesn't look right. Note that a non-US number needs a "+" before the number.`; return ( $(field).attr('data-alInvalidInputMessage') // The default message could be confusing for invalid US phone numbers - || `This phone number doesn't look right. Note that a non-US number needs a "+" before the number.` + || (typeof window.alTranslate === 'function' + ? window.alTranslate(defaultMessage) + : defaultMessage) ) }; diff --git a/docassemble/ALToolbox/data/questions/phone-number-validation.yml b/docassemble/ALToolbox/data/questions/phone-number-validation.yml index 344546d..8d8b3d2 100644 --- a/docassemble/ALToolbox/data/questions/phone-number-validation.yml +++ b/docassemble/ALToolbox/data/questions/phone-number-validation.yml @@ -12,6 +12,7 @@ comment: | features: javascript: - "https://cdn.jsdelivr.net/npm/intl-tel-input@25.12.5/build/js/intlTelInput.min.js" + - al_translate.js - phone-number-validation.js css: - "https://cdn.jsdelivr.net/npm/intl-tel-input@25.12.5/build/css/intlTelInput.min.css" @@ -21,3 +22,4 @@ features: --- modules: - .PhoneNumberDataType + - .translation_strings diff --git a/docassemble/ALToolbox/data/sources/altoolbox_words.yml b/docassemble/ALToolbox/data/sources/altoolbox_words.yml index 032f54b..bd184d4 100644 --- a/docassemble/ALToolbox/data/sources/altoolbox_words.yml +++ b/docassemble/ALToolbox/data/sources/altoolbox_words.yml @@ -4,9 +4,149 @@ # words: # - docassemble.ALToolbox:data/sources/altoolbox_words.yml # -# The same format works for your own strings. Copy this file into your package's -# data/sources directory, add your languages and phrases, and list it under -# `words:` alongside this one. +# AssemblyLine includes these entries in its standard word catalogs. This file +# remains a fallback for servers that use ALToolbox without AssemblyLine. +ar: + "Change country, selected ${countryName} (${dialCode})": "تغيير البلد، المحدد حاليًا ${countryName} (${dialCode})" + "Select country": "حدد الدولة" + "List of countries": "قائمة الدول" + "Search": "يبحث" + "Clear search": "مسح البحث" + "No results found": "لم يتم العثور على نتائج" + "1 result found": "تم العثور على نتيجة واحدة" + "${count} results found": "تم العثور على ${count} نتيجة" + 'This phone number doesn''t look right. Note that a non-US number needs a "+" before the number.': 'يبدو أن رقم الهاتف هذا غير صحيح. لاحظ أن الرقم غير الأمريكي يحتاج إلى "+" قبل الرقم.' + "You have entered %d character.": "لقد أدخلت %d حرفًا." + "You have entered %d characters.": "لقد أدخلت %d أحرف." es: - You have entered %d character.: Ha escrito %d carácter. - You have entered %d characters.: Ha escrito %d caracteres. + "Change country, selected ${countryName} (${dialCode})": "Cambiar país, seleccionado ${countryName} (${dialCode})" + "Select country": "Seleccionar país" + "List of countries": "Lista de países" + "Search": "Buscar" + "Clear search": "Borrar búsqueda" + "No results found": "No se han encontrado resultados" + "1 result found": "1 resultado encontrado" + "${count} results found": "Se encontraron ${count} resultados" + 'This phone number doesn''t look right. Note that a non-US number needs a "+" before the number.': 'Este número de teléfono no parece correcto. Tenga en cuenta que un número que no es de EE. UU. necesita un "+" antes del número.' + "You have entered %d character.": "Ha escrito %d carácter." + "You have entered %d characters.": "Ha escrito %d caracteres." +fr: + "Change country, selected ${countryName} (${dialCode})": "Changer de pays, ${countryName} sélectionné (${dialCode})" + "Select country": "Sélectionner un pays" + "List of countries": "Liste des pays" + "Search": "Recherche" + "Clear search": "Effacer la recherche" + "No results found": "Aucun résultat trouvé" + "1 result found": "1 résultat trouvé" + "${count} results found": "${count} résultats trouvés" + 'This phone number doesn''t look right. Note that a non-US number needs a "+" before the number.': 'Ce numéro de téléphone ne semble pas correct. Notez qu’un numéro non américain doit être précédé d’un "+".' + "You have entered %d character.": "Vous avez saisi %d caractère." + "You have entered %d characters.": "Vous avez saisi %d caractères." +ht: + "Change country, selected ${countryName} (${dialCode})": "Chanje peyi, peyi chwazi ${countryName} (${dialCode})" + "Select country": "Chwazi peyi" + "List of countries": "Lis peyi yo" + "Search": "Rechèch" + "Clear search": "Efase rechèch" + "No results found": "Pa jwenn okenn rezilta" + "1 result found": "Yo jwenn 1 rezilta" + "${count} results found": "Yo jwenn ${count} rezilta" + 'This phone number doesn''t look right. Note that a non-US number needs a "+" before the number.': 'Nimewo telefòn sa a pa sanble kòrèk. Tanpri sonje yon nimewo ki pa ameriken bezwen yon "+" anvan nimewo a.' + "You have entered %d character.": "Ou antre %d karaktè." + "You have entered %d characters.": "Ou antre %d karaktè." +km: + "Change country, selected ${countryName} (${dialCode})": "ប្តូរប្រទេស ប្រទេសដែលបានជ្រើសរើស ${countryName} (${dialCode})" + "Select country": "ជ្រើសរើសប្រទេស" + "List of countries": "បញ្ជីប្រទេស" + "Search": "ស្វែងរក" + "Clear search": "សម្អាតការស្វែងរក" + "No results found": "រកមិនឃើញលទ្ធផល" + "1 result found": "រកឃើញលទ្ធផល ១" + "${count} results found": "រកឃើញលទ្ធផល ${count}" + 'This phone number doesn''t look right. Note that a non-US number needs a "+" before the number.': 'លេខទូរសព្ទនេះមើលទៅមិនត្រឹមត្រូវទេ។ សូមចំណាំថា លេខទូរសព្ទដែលមិនមែនជាលេខអាមេរិក ត្រូវដាក់សញ្ញា "+" នៅពីមុខលេខ។' + "You have entered %d character.": "អ្នកបានបញ្ចូល %d តួអក្សរ។" + "You have entered %d characters.": "អ្នកបានបញ្ចូល %d តួអក្សរ។" +pt: + "Change country, selected ${countryName} (${dialCode})": "Alterar país, país selecionado ${countryName} (${dialCode})" + "Select country": "Selecionar país" + "List of countries": "Lista de países" + "Search": "Procurar" + "Clear search": "Limpar pesquisa" + "No results found": "Nenhum resultado encontrado" + "1 result found": "1 resultado encontrado" + "${count} results found": "${count} resultados encontrados" + 'This phone number doesn''t look right. Note that a non-US number needs a "+" before the number.': 'Este número de telefone não parece correto. Observe que um número que não seja dos EUA precisa de um "+" antes do número.' + "You have entered %d character.": "Você digitou %d caractere." + "You have entered %d characters.": "Você digitou %d caracteres." +vi: + "Change country, selected ${countryName} (${dialCode})": "Đổi quốc gia, quốc gia đã chọn ${countryName} (${dialCode})" + "Select country": "Chọn quốc gia" + "List of countries": "Danh sách các quốc gia" + "Search": "Tìm kiếm" + "Clear search": "Xóa tìm kiếm" + "No results found": "Không tìm thấy kết quả nào" + "1 result found": "Đã tìm thấy 1 kết quả" + "${count} results found": "Đã tìm thấy ${count} kết quả" + 'This phone number doesn''t look right. Note that a non-US number needs a "+" before the number.': 'Số điện thoại này có vẻ không đúng. Lưu ý rằng số điện thoại không phải của Hoa Kỳ cần có dấu "+" trước số.' + "You have entered %d character.": "Bạn đã nhập %d ký tự." + "You have entered %d characters.": "Bạn đã nhập %d ký tự." +zh-Hans: + "Change country, selected ${countryName} (${dialCode})": "更改国家/地区,当前选定的国家/地区为 ${countryName} (${dialCode})" + "Select country": "选择国家/地区" + "List of countries": "国家/地区列表" + "Search": "搜索" + "Clear search": "清除搜索" + "No results found": "未找到结果" + "1 result found": "找到 1 个结果" + "${count} results found": "找到 ${count} 个结果" + 'This phone number doesn''t look right. Note that a non-US number needs a "+" before the number.': '此电话号码似乎不正确。请注意,非美国电话号码需要在号码前加上“+”。' + "You have entered %d character.": "您已输入 %d 个字符。" + "You have entered %d characters.": "您已输入 %d 个字符。" +zh-CN: + "Change country, selected ${countryName} (${dialCode})": "更改国家/地区,当前选定的国家/地区为 ${countryName} (${dialCode})" + "Select country": "选择国家/地区" + "List of countries": "国家/地区列表" + "Search": "搜索" + "Clear search": "清除搜索" + "No results found": "未找到结果" + "1 result found": "找到 1 个结果" + "${count} results found": "找到 ${count} 个结果" + 'This phone number doesn''t look right. Note that a non-US number needs a "+" before the number.': '此电话号码似乎不正确。请注意,非美国电话号码需要在号码前加上“+”。' + "You have entered %d character.": "您已输入 %d 个字符。" + "You have entered %d characters.": "您已输入 %d 个字符。" +zh-Hant: + "Change country, selected ${countryName} (${dialCode})": "更改國家/地區,目前選擇的國家/地區為 ${countryName} (${dialCode})" + "Select country": "選擇國家/地區" + "List of countries": "國家/地區清單" + "Search": "搜尋" + "Clear search": "清除搜尋" + "No results found": "找不到結果" + "1 result found": "找到 1 個結果" + "${count} results found": "找到 ${count} 個結果" + 'This phone number doesn''t look right. Note that a non-US number needs a "+" before the number.': '此電話號碼似乎不正確。請注意,非美國電話號碼需要在號碼前加上「+」。' + "You have entered %d character.": "您已輸入 %d 個字元。" + "You have entered %d characters.": "您已輸入 %d 個字元。" +zh-TW: + "Change country, selected ${countryName} (${dialCode})": "更改國家/地區,目前選擇的國家/地區為 ${countryName} (${dialCode})" + "Select country": "選擇國家/地區" + "List of countries": "國家/地區清單" + "Search": "搜尋" + "Clear search": "清除搜尋" + "No results found": "找不到結果" + "1 result found": "找到 1 個結果" + "${count} results found": "找到 ${count} 個結果" + 'This phone number doesn''t look right. Note that a non-US number needs a "+" before the number.': '此電話號碼似乎不正確。請注意,非美國電話號碼需要在號碼前加上「+」。' + "You have entered %d character.": "您已輸入 %d 個字元。" + "You have entered %d characters.": "您已輸入 %d 個字元。" +ne: + "Change country, selected ${countryName} (${dialCode})": "देश परिवर्तन गर्नुहोस्, हाल चयन गरिएको ${countryName} (${dialCode})" + "Select country": "देश चयन गर्नुहोस्" + "List of countries": "देशहरूको सूची" + "Search": "खोज्नुहोस्" + "Clear search": "खोजी खाली गर्नुहोस्" + "No results found": "कुनै परिणाम फेला परेन" + "1 result found": "१ परिणाम फेला पर्यो" + "${count} results found": "${count} वटा परिणाम फेला परे" + 'This phone number doesn''t look right. Note that a non-US number needs a "+" before the number.': 'यो फोन नम्बर सही जस्तो देखिँदैन। अमेरिकाबाहिरको नम्बरअघि "+" राख्नुपर्छ भन्ने कुरा ध्यान दिनुहोस्।' + "You have entered %d character.": "तपाईंले %d अक्षर प्रविष्ट गर्नुभएको छ।" + "You have entered %d characters.": "तपाईंले %d अक्षरहरू प्रविष्ट गर्नुभएको छ।" diff --git a/docassemble/ALToolbox/data/static/phone-number-validation.js b/docassemble/ALToolbox/data/static/phone-number-validation.js index f6b30e5..f6eb15d 100644 --- a/docassemble/ALToolbox/data/static/phone-number-validation.js +++ b/docassemble/ALToolbox/data/static/phone-number-validation.js @@ -1,4 +1,66 @@ -$(document).on('daPageLoad', function(){ +(function () { + function currentLanguage() { + return ( + window.alTranslationsLang || + document.documentElement.getAttribute("lang") || + "en" + ); + } + + function translate(text) { + return typeof window.alTranslate === "function" + ? window.alTranslate(text) + : text; + } + + function getUiTranslations() { + return { + selectedCountryAriaLabel: translate( + "Change country, selected ${countryName} (${dialCode})" + ), + noCountrySelected: translate("Select country"), + countryListAriaLabel: translate("List of countries"), + searchPlaceholder: translate("Search"), + clearSearchAriaLabel: translate("Clear search"), + zeroSearchResults: translate("No results found"), + oneSearchResult: translate("1 result found"), + multipleSearchResults: translate("${count} results found"), + }; + } + + function getI18n() { + var translations = getUiTranslations(); + if ( + typeof Intl === "undefined" || + typeof Intl.DisplayNames !== "function" || + !window.intlTelInput.getCountryData + ) { + return translations; + } + + try { + var regionNames = new Intl.DisplayNames( + [currentLanguage().replace(/_/g, "-")], + { type: "region" } + ); + window.intlTelInput.getCountryData().forEach(function (country) { + try { + var name = regionNames.of(country.iso2.toUpperCase()); + if (name) { + translations[country.iso2] = name; + } + } catch (error) { + // A private-use or otherwise unknown country code can be skipped. + } + }); + } catch (error) { + // If the browser has no country names for this locale, keep the + // translated interface and let intl-tel-input use its English names. + } + return translations; + } + + function setupPhoneInputs() { /** When the page first loads, gets the value from any `.al_international_phone` input * field, transforms it, puts the transformed value back into the input * field, and adds a dropdown from which to choose a country. It @@ -30,20 +92,26 @@ $(document).on('daPageLoad', function(){ * 1. https://github.com/google/libphonenumber/blob/master/FAQ.md */ - // Loop through all the .al_international_phone input fields on the current screen - let phoneNodes = document.querySelectorAll( '.al_international_phone' ); // Class given by the CustomDataType - for ( var node of phoneNodes ) { - var telObj = window.intlTelInput( node, { + // Loop through all the .al_international_phone input fields on the current screen + let phoneNodes = document.querySelectorAll( '.al_international_phone' ); // Class given by the CustomDataType + for ( var node of phoneNodes ) { + if (node._alToolboxPhoneInstance) { + node._alToolboxPhoneInstance.destroy(); + } + + var telObj = window.intlTelInput( node, { // The default country without any input into the plugin is 'us' // Once the user puts in the phone number of another country, though, // it will remember that country initialCountry: 'us', + i18n: getI18n(), loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.12.5/build/js/utils.js"), autoPlaceholder: "off", formatAsYouType: false, // Avoid default of MOBILE (https://github.com/jackocnr/intl-tel-input?tab=readme-ov-file#:~:text=validationNumberTypes,-Type%3A%20String) validationNumberTypes: null, - }); + }); + node._alToolboxPhoneInstance = telObj; // If the user already entered a number and comes back to the page, // docassemble puts the previously saved value into the input field. @@ -52,7 +120,10 @@ $(document).on('daPageLoad', function(){ // can see it if you go forward and back three times on our demo screens. // See https://www.npmjs.com/package/intl-tel-input#recommended-usage about the full international format. - $(node).val(telObj.getNumber()); - }; - -}); + $(node).val(telObj.getNumber()); + }; + } + + $(document).on('daPageLoad', setupPhoneInputs); + window.addEventListener("alTranslationsLoaded", setupPhoneInputs); +})(); diff --git a/docassemble/ALToolbox/test_translation_strings.py b/docassemble/ALToolbox/test_translation_strings.py index 8eb340e..c00f6b5 100644 --- a/docassemble/ALToolbox/test_translation_strings.py +++ b/docassemble/ALToolbox/test_translation_strings.py @@ -162,16 +162,38 @@ class TestAltoolboxJsStrings(unittest.TestCase): def _static_path(self, name: str) -> str: return os.path.join(os.path.dirname(__file__), "data", "static", name) - def test_every_string_passed_to_underscore_is_listed(self) -> None: + def test_loader_uses_the_json_catalog_endpoint(self) -> None: + with open(self._static_path("al_translate.js"), encoding="utf-8") as loader: + source = loader.read() + self.assertIn("al_translations.json?lang=", source) + self.assertNotIn("al_translations.js?lang=", source) + + def test_every_string_passed_to_the_translator_is_listed(self) -> None: with open(self._static_path("TextCounter.js"), encoding="utf-8") as counter_js: source = counter_js.read() - found = set(re.findall(r'\b_\(\s*"((?:[^"\\]|\\.)*)"', source)) - self.assertTrue(found, "expected TextCounter.js to call _()") + with open( + self._static_path("phone-number-validation.js"), encoding="utf-8" + ) as phone_js: + source += phone_js.read() + found = set( + re.findall(r'\b_\(\s*"((?:[^"\\]|\\.)*)"', source) + + re.findall(r'\btranslate\(\s*"((?:[^"\\]|\\.)*)"', source) + ) + self.assertTrue(found, "expected ALToolbox JavaScript to call a translator") self.assertTrue( found.issubset(set(ALTOOLBOX_JS_STRINGS)), f"missing from ALTOOLBOX_JS_STRINGS: {found - set(ALTOOLBOX_JS_STRINGS)}", ) + with open( + os.path.join(os.path.dirname(__file__), "PhoneNumberDataType.py"), + encoding="utf-8", + ) as phone_datatype: + self.assertIn( + 'This phone number doesn\'t look right. Note that a non-US number needs a "+" before the number.', + phone_datatype.read(), + ) + def test_the_shipped_word_file_covers_every_string(self) -> None: import yaml # type: ignore[import-untyped] diff --git a/docassemble/ALToolbox/translation_strings.py b/docassemble/ALToolbox/translation_strings.py index 5fdb473..bbdce89 100644 --- a/docassemble/ALToolbox/translation_strings.py +++ b/docassemble/ALToolbox/translation_strings.py @@ -33,10 +33,19 @@ __all__ = ["add_translations", "translate", "translation_catalog"] -# Every string ALToolbox's own JavaScript passes to `_()`. Nothing reads this at -# runtime; it is the checklist a translator needs, and a test keeps it honest by -# grepping the shipped .js files. +# Every string ALToolbox's own JavaScript or custom datatype passes to the +# translator. Nothing reads this at runtime; it is the checklist a translator +# needs, and a test keeps it honest by grepping the shipped source files. ALTOOLBOX_JS_STRINGS = [ + "Change country, selected ${countryName} (${dialCode})", + "Select country", + "List of countries", + "Search", + "Clear search", + "No results found", + "1 result found", + "${count} results found", + 'This phone number doesn\'t look right. Note that a non-US number needs a "+" before the number.', "You have entered %d character.", "You have entered %d characters.", ]