Repository: e-valuation/EvaP

Issue:
Translations in Javascript and Typescript
When writing Javascript and Typescript in separate, non-HTML files, we can't use the Django template functions `trans`, `blocktrans`, etc. anymore. We have worked around this by putting translated strings into the DOM and accessing them via Javascript then.

Instead of doing this, we want to have a unified approach where the use-site can just write `trans("The server is not responding.")` or so. There are two possible approaches:

1. DIY: We have a function `trans(english: string, to: Language = window.LANGUAGE): string` with `type Language = "English" | "German"`. This function looks up the string in a global dictionary (for example `window.translationDictionary` or so). I am not sure what it should do if the string is not present, probably return the English string and emit a warning? This dictionary would be defined in a script tag in a HTML file, something like (possibly with an implementation that doesn't repeat the strings a little less):
```html
<script type="text/javascript">
    window.translationDictionary = {
        "de": {
            {% language 'de' %}
                "The server is not responding": "{% trans 'The server is not responding' %}",
            {% endlanguage %}
        }
    };
</script>
```
2. Use Django's builtin functionality: There is a builtin way that configures an extra endpoint to make all translations available (https://docs.djangoproject.com/en/4.2/topics/i18n/translation/#internationalization-in-javascript-code). A plus is that it also supports `ngettext` and so on. It seems like it can also detect all strings used in translations, but the setup may be a bit tricky with Typescript thrown into the mix.

I think I prefer the first approach, but maybe we encounter difficulties with it or decide that we will need `ngettext` etc. in the future and go with the Django versions directly.


Discussion:

Resolving patch:
diff --git a/evap/development/management/commands/translate.py b/evap/development/management/commands/translate.py
--- 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/urls.py b/evap/urls.py
--- 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:


Test patch:
diff --git a/evap/evaluation/tests/test_views.py b/evap/evaluation/tests/test_views.py
--- 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
--- 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/static/ts/tests/utils/page.ts b/evap/static/ts/tests/utils/page.ts
--- 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<Page> {
         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();
         }