From 431077a06101500db6c884b72840544e12f9be48 Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 6 Nov 2023 21:38:14 +0100 Subject: [PATCH] Improve translation handling in JavaScript and TypeScript (#2036) * Introduce i18n for JS/TS using Django's javascript catalog * Extract sortable_form.js.html to sortable_form.js --------- Co-authored-by: Richard Ebeling --- .../management/commands/translate.py | 8 +++ evap/evaluation/templates/base.html | 2 + .../templates/evap_evaluation_edit_js.html | 4 +- evap/evaluation/templates/notebook.html | 2 +- .../templates/sortable_form_js.html | 62 ------------------- evap/evaluation/tests/test_views.py | 15 ++++- evap/evaluation/tests/tools.py | 21 ++++--- evap/locale/de/LC_MESSAGES/djangojs.po | 30 +++++++++ .../templates/staff_course_type_index.html | 4 +- evap/staff/templates/staff_degree_index.html | 4 +- evap/staff/templates/staff_faq_index.html | 4 +- evap/staff/templates/staff_faq_section.html | 4 +- .../templates/staff_questionnaire_form.html | 4 +- .../templates/staff_semester_export.html | 4 +- .../templates/staff_text_answer_warnings.html | 2 +- evap/static/js/.gitignore | 1 + evap/static/js/sortable_form.js | 60 ++++++++++++++++++ evap/static/ts/src/notebook.ts | 3 +- evap/static/ts/src/translation.ts | 16 +++++ evap/static/ts/tests/utils/page.ts | 12 ++++ evap/urls.py | 5 ++ 21 files changed, 185 insertions(+), 82 deletions(-) delete mode 100644 evap/evaluation/templates/sortable_form_js.html create mode 100644 evap/locale/de/LC_MESSAGES/djangojs.po create mode 100644 evap/static/js/sortable_form.js create mode 100644 evap/static/ts/src/translation.ts diff --git a/evap/development/management/commands/translate.py b/evap/development/management/commands/translate.py index 604418df08..929fdfdeeb 100644 --- a/evap/development/management/commands/translate.py +++ b/evap/development/management/commands/translate.py @@ -9,3 +9,11 @@ class Command(BaseCommand): def handle(self, *args, **options): self.stdout.write('Executing "manage.py makemessages --locale=de --ignore=node_modules/*"') call_command("makemessages", "--locale=de", "--ignore=node_modules/*") + call_command( + "makemessages", + "--domain=djangojs", + "--extension=js,ts", + "--locale=de", + "--ignore=node_modules/*", + "--ignore=evap/static/js/*.min.js", + ) diff --git a/evap/evaluation/templates/base.html b/evap/evaluation/templates/base.html index f82fcb2465..bcb058690c 100644 --- a/evap/evaluation/templates/base.html +++ b/evap/evaluation/templates/base.html @@ -71,6 +71,8 @@ {% include 'footer.html' %} + + diff --git a/evap/evaluation/templates/evap_evaluation_edit_js.html b/evap/evaluation/templates/evap_evaluation_edit_js.html index 9fe79b85c1..601252c6d6 100644 --- a/evap/evaluation/templates/evap_evaluation_edit_js.html +++ b/evap/evaluation/templates/evap_evaluation_edit_js.html @@ -1,5 +1,7 @@ +{% load static %} + {% if editable %} - {% include 'sortable_form_js.html' %} + diff --git a/evap/evaluation/tests/test_views.py b/evap/evaluation/tests/test_views.py index b2ac832889..2af1ac6d24 100644 --- a/evap/evaluation/tests/test_views.py +++ b/evap/evaluation/tests/test_views.py @@ -8,7 +8,20 @@ from model_bakery import baker from evap.evaluation.models import Evaluation, Question, QuestionType, UserProfile -from evap.evaluation.tests.tools import WebTestWith200Check, create_evaluation_with_responsible_and_editor +from evap.evaluation.tests.tools import ( + WebTestWith200Check, + create_evaluation_with_responsible_and_editor, + store_ts_test_asset, +) + + +class RenderJsTranslationCatalog(WebTest): + url = reverse("javascript-catalog") + + def render_pages(self): + # Not using render_pages decorator to manually create a single (special) javascript file + content = self.app.get(self.url).content + store_ts_test_asset("catalog.js", content) @override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) diff --git a/evap/evaluation/tests/tools.py b/evap/evaluation/tests/tools.py index c687c9c189..55a60127e6 100644 --- a/evap/evaluation/tests/tools.py +++ b/evap/evaluation/tests/tools.py @@ -86,6 +86,15 @@ def let_user_vote_for_evaluation(user, evaluation, create_answers=False): RatingAnswerCounter.objects.bulk_update(rac_by_contribution_question.values(), ["count"]) +def store_ts_test_asset(relative_path: str, content) -> None: + absolute_path = os.path.join(settings.STATICFILES_DIRS[0], "ts", "rendered", relative_path) + + os.makedirs(os.path.dirname(absolute_path), exist_ok=True) + + with open(absolute_path, "wb") as file: + file.write(content) + + def render_pages(test_item): """Decorator which annotates test methods which render pages. The containing class is expected to include a `url` attribute which matches a valid path. @@ -94,19 +103,15 @@ def render_pages(test_item): The value is a byte string of the page content.""" @functools.wraps(test_item) - def decorator(self): + def decorator(self) -> None: pages = test_item(self) - static_directory = settings.STATICFILES_DIRS[0] - url = getattr(self, "render_pages_url", self.url) - # Remove the leading slash from the url to prevent that an absolute path is created - directory = os.path.join(static_directory, "ts", "rendered", url[1:]) - os.makedirs(directory, exist_ok=True) for name, content in pages.items(): - with open(os.path.join(directory, f"{name}.html"), "wb") as html_file: - html_file.write(content) + # Remove the leading slash from the url to prevent that an absolute path is created + path = os.path.join(url[1:], f"{name}.html") + store_ts_test_asset(path, content) return decorator diff --git a/evap/locale/de/LC_MESSAGES/djangojs.po b/evap/locale/de/LC_MESSAGES/djangojs.po new file mode 100644 index 0000000000..120570d0e0 --- /dev/null +++ b/evap/locale/de/LC_MESSAGES/djangojs.po @@ -0,0 +1,30 @@ +# EvaP translation +# This file is distributed under the same license as the EvaP project. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: EvaP\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-10-30 17:26+0100\n" +"PO-Revision-Date: 2023-10-30 17:26+0100\n" +"Last-Translator: Johannes Wolf \n" +"Language-Team: Johannes Wolf (janno42)\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 3.3.2\n" + +#: evap/static/js/notebook.js:30 evap/static/ts/src/notebook.ts:39 +msgid "The server is not responding." +msgstr "" + +#: evap/static/js/sortable_form.js:19 +msgid "Delete" +msgstr "" + +#: evap/static/js/sortable_form.js:20 +msgid "add another" +msgstr "" diff --git a/evap/staff/templates/staff_course_type_index.html b/evap/staff/templates/staff_course_type_index.html index 9227366b0f..95c7909c61 100644 --- a/evap/staff/templates/staff_course_type_index.html +++ b/evap/staff/templates/staff_course_type_index.html @@ -1,5 +1,7 @@ {% extends 'staff_base.html' %} +{% load static %} + {% block breadcrumb %} {{ block.super }} @@ -71,7 +73,7 @@ {% endblock %} {% block additional_javascript %} - {% include 'sortable_form_js.html' %} + {{ text_answer_warnings|text_answer_warning_trigger_strings|json_script:'text-answer-warnings' }} diff --git a/evap/static/js/.gitignore b/evap/static/js/.gitignore index 1aa5190593..71ed8340ba 100644 --- a/evap/static/js/.gitignore +++ b/evap/static/js/.gitignore @@ -1,3 +1,4 @@ # ignore the typescript output, but still track the libraries *.js !*.min.js +!sortable_form.js diff --git a/evap/static/js/sortable_form.js b/evap/static/js/sortable_form.js new file mode 100644 index 0000000000..2c4349b346 --- /dev/null +++ b/evap/static/js/sortable_form.js @@ -0,0 +1,60 @@ +function makeFormSortable(tableId, prefix, rowChanged, rowAdded, tolerance, removeAsButton, usesTemplate) { + + function applyOrdering() { + $(document).find('tr').each(function(i) { + if (rowChanged($(this))) { + $(this).find('input[id$=-order]').val(i); + } + else { + // if the row is empty (has no text in the input fields) set the order to -1 (default), + // so that the one extra row doesn't change its initial value + $(this).find('input[id$=-order]').val(-1); + } + }); + } + + $('#' + tableId + ' tbody tr').formset({ + prefix: prefix, + deleteCssClass: removeAsButton ? 'btn btn-danger btn-sm' : 'delete-row', + deleteText: removeAsButton ? '' : gettext('Delete'), + addText: gettext('add another'), + added: function(row) { + row.find('input[id$=-order]').val(row.parent().children().length); + + // We have to empty the formset, otherwise sometimes old contents from + // invalid forms are copied (#644). + // Checkboxes with 'data-keep' need to stay checked. + row.find("input:checkbox:not([data-keep]),:radio").removeAttr("checked"); + + row.find("input:text,textarea").val(""); + + row.find("select").each(function(){ + $(this).find('option:selected').removeAttr("selected"); + $(this).find('option').first().attr("selected", "selected"); + }); + + //Check the first item in every button group + row.find(".btn-group").each(function() { + var inputs = $(this).find("input"); + $(inputs[0]).prop("checked", true); + }); + + //Remove all error messages + row.find(".error-label").remove(); + + rowAdded(row); + }, + formTemplate: (usesTemplate ? ".form-template" : null) + }); + + new Sortable($('#' + tableId + " tbody").get(0), { + handle: ".fa-up-down", + draggable: ".sortable", + scrollSensitivity: 70 + }); + + $('form').submit(function() { + applyOrdering(); + return true; + }); +} diff --git a/evap/static/ts/src/notebook.ts b/evap/static/ts/src/notebook.ts index 426821ef56..c5fcf84d54 100644 --- a/evap/static/ts/src/notebook.ts +++ b/evap/static/ts/src/notebook.ts @@ -1,3 +1,4 @@ +import "./translation.js"; import { unwrap, assert, selectOrError } from "./utils.js"; const NOTEBOOK_LOCALSTORAGE_KEY = "evap_notebook_open"; @@ -35,7 +36,7 @@ class NotebookFormLogic { .catch(() => { this.notebook.setAttribute("data-state", "ready"); submitter.disabled = false; - alert(submitter.dataset.errormessage); + alert(window.gettext("The server is not responding.")); }); }; diff --git a/evap/static/ts/src/translation.ts b/evap/static/ts/src/translation.ts new file mode 100644 index 0000000000..b56bbcc838 --- /dev/null +++ b/evap/static/ts/src/translation.ts @@ -0,0 +1,16 @@ +// Django's own JavaScript translation catalog is provided as global JS +// In order to make it usable with TypeScript, additional type definitons +// are necessary: +export {}; +declare global { + interface Window { + gettext(msgid: string): string; + pluralidx(n: number | boolean): number; + ngettext(singular: string, plural: string, count: number): string; + gettext_noop(msgid: string): string; + pgettext(context: string, msgid: string): string; + npgettext(context: string, singular: string, plural: string, count: number): string; + interpolate(fmt: string, obj: any, named: boolean): string; + get_format(format_type: string): string; + } +} diff --git a/evap/static/ts/tests/utils/page.ts b/evap/static/ts/tests/utils/page.ts index 60a327355f..0cb49c205a 100644 --- a/evap/static/ts/tests/utils/page.ts +++ b/evap/static/ts/tests/utils/page.ts @@ -20,14 +20,26 @@ async function createPage(browser: Browser): Promise { const extension = path.extname(request.url()); const pathname = new URL(request.url()).pathname; if (extension === ".html") { + // requests like /evap/evap/static/ts/rendered/results/student.html request.continue(); } else if (pathname.startsWith(staticPrefix)) { + // requests like /static/css/tom-select.bootstrap5.min.css const asset = pathname.substr(staticPrefix.length); const body = fs.readFileSync(path.join(__dirname, "..", "..", "..", asset)); request.respond({ contentType: contentTypeByExtension.get(extension), body, }); + } else if (pathname.endsWith("catalog.js")) { + // request for /catalog.js + // some pages will error out if translation functions are not available + // rendered in RenderJsTranslationCatalog + const absolute_fs_path = path.join(__dirname, "..", "..", "..", "ts", "rendered", "catalog.js"); + const body = fs.readFileSync(absolute_fs_path); + request.respond({ + contentType: contentTypeByExtension.get(extension), + body, + }); } else { request.abort(); } diff --git a/evap/urls.py b/evap/urls.py index ad5f5d7b78..08fd0e0c8b 100644 --- a/evap/urls.py +++ b/evap/urls.py @@ -1,6 +1,9 @@ import django.contrib.auth.views from django.conf import settings from django.urls import include, path +from django.views.i18n import JavaScriptCatalog + +from evap.middleware import no_login_required urlpatterns = [ path("", include('evap.evaluation.urls')), @@ -13,6 +16,8 @@ path("logout", django.contrib.auth.views.LogoutView.as_view(next_page="/"), name="django-auth-logout"), path("oidc/", include('mozilla_django_oidc.urls')), + + path("catalog.js", no_login_required(JavaScriptCatalog.as_view()), name="javascript-catalog"), ] if settings.DEBUG: