diff --git a/docs/pages/admin.rst b/docs/pages/admin.rst index cb802f9..800527a 100644 --- a/docs/pages/admin.rst +++ b/docs/pages/admin.rst @@ -7,6 +7,28 @@ By default, each field is displayed for each language configured for django-mode This might work for a couple of languages, but with 2 translated fields and 10 languages, it already is a bit unwieldy. +`django-modeltrans` provides two mix-ins to improve the editing experience. + +`TabbedLanguageMixin` +--------------------- + +The `TabbedLanguageMixin` creates a tab for each language for all translated fields. +Use like this:: + + from django.contrib import admin + from modeltrans.admin import TabbedLanguageMixin + + from .models import Blog + + + @admin.register(Blog) + class BlogAdmin(TabbedLanguageMixin, admin.ModelAdmin): + pass + + +`ActiveLanguageMixin` +--------------------- + The `ActiveLanguageMixin` is provided to show only the default language (`settings.LANGUAGE_CODE`) and the currently active language. Use like this:: @@ -19,3 +41,4 @@ the currently active language. Use like this:: @admin.register(Blog) class BlogAdmin(ActiveLanguageMixin, admin.ModelAdmin): pass + diff --git a/example/app/admin.py b/example/app/admin.py index 89d3801..b1ac833 100644 --- a/example/app/admin.py +++ b/example/app/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from modeltrans.admin import ActiveLanguageMixin +from modeltrans.admin import ActiveLanguageMixin, TabbedLanguageMixin from .models import Blog, Category from .utils import disable_admin_login @@ -9,7 +9,7 @@ @admin.register(Blog) -class BlogAdmin(ActiveLanguageMixin, admin.ModelAdmin): +class BlogAdmin(TabbedLanguageMixin, admin.ModelAdmin): list_display = ("title_i18n", "category") search_fields = ("title_i18n", "category__name_i18n") diff --git a/modeltrans/admin.py b/modeltrans/admin.py index 80450ee..3648e8f 100644 --- a/modeltrans/admin.py +++ b/modeltrans/admin.py @@ -1,4 +1,5 @@ from .conf import get_default_language +from .fields import TranslatedVirtualField from .translator import get_i18n_field from .utils import get_language @@ -33,3 +34,71 @@ def get_exclude(self, request, obj=None): # de-duplicate return list(set(excludes)) + + +class TabbedLanguageMixin: + """ModelAdmin mixin to giving access to all languages for each i18n field using tabs.""" + + class Media: + css = { + "all": ("modeltrans/css/i18n_tabs.css",), + } + js = ("modeltrans/js/i18n_tabs.js",) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.i18n_field = get_i18n_field(self.model) + + def formfield_for_dbfield(self, db_field, request=None, **kwargs): + field = super().formfield_for_dbfield(db_field, request, **kwargs) + if self.i18n_field is None: + return field + + if isinstance(db_field, TranslatedVirtualField): + field.widget.attrs["data-i18n-lang"] = db_field.language or "" + field.widget.attrs["data-i18n-field"] = db_field.original_name + if not db_field.language: + field.widget.attrs["required"] = not db_field.original_field.blank + elif db_field.language == get_default_language(): + field.widget.attrs["data-i18n-default"] = "true" + + return field + + def get_fieldsets(self, request, obj=None): + fieldsets = super().get_fieldsets(request, obj) + + if self.i18n_field is None or not fieldsets: + return fieldsets + + fieldsets = list(fieldsets) + + real_to_virtual_fields = {} + virtual_field_names = set() + for field in self.i18n_field.get_translated_fields(): + virtual_field_names.add(field.name) + + # Remove _i18n fields from fieldsets + if field.language is None: + continue + + if field.original_name not in real_to_virtual_fields: + real_to_virtual_fields[field.original_name] = [] + real_to_virtual_fields[field.original_name].append(field.name) + + translated_fieldsets = [] + for label, fieldset in fieldsets: + field_names = [] + for field_name in fieldset.get("fields", []): + if field_name in real_to_virtual_fields: + field_names.append([field_name] + sorted(real_to_virtual_fields[field_name])) + + elif field_name not in virtual_field_names: + field_names.append(field_name) + + new_fieldset = { + "fields": field_names, + "classes": fieldset.get("classes", []), + } + translated_fieldsets.append((label, new_fieldset)) + + return translated_fieldsets diff --git a/modeltrans/static/modeltrans/css/i18n_tabs.css b/modeltrans/static/modeltrans/css/i18n_tabs.css new file mode 100644 index 0000000..6ebb23d --- /dev/null +++ b/modeltrans/static/modeltrans/css/i18n_tabs.css @@ -0,0 +1,40 @@ +.i18n-tabs {} + +.i18n-tab-buttons { + display: flex; + justify-content: start; + gap: 5px; + border-bottom: 1px solid var(--border-color, #ccc); +} + +.i18n-button { + font: inherit; + padding: 5px 10px; + border: 1px solid var(--border-color, #ccc); + border-bottom: none; + border-radius: 4px 4px 0 0; + cursor: pointer; + color: var(--body-quiet-color); + background: transparent; + text-transform: uppercase; +} + +.i18n-button:hover { + color: var(--admin-interface-generic-link-hover-color); +} + +.i18n-button.errors { + color: var(--error-fg, var(--admin-interface-error-text-color)); + border-color: var(--error-fg, var(--admin-interface-error-text-color)); +} + +.i18n-button.active { + margin-bottom: -1px; + padding-bottom: 6px; + color: var(--admin-interface-save-button-text-color, var(--button-fg)); + background: var(--admin-interface-save-button-background-color, var(--button-bg)); +} + +.i18n-tab { + padding-top: 1em; +} \ No newline at end of file diff --git a/modeltrans/static/modeltrans/js/i18n_tabs.js b/modeltrans/static/modeltrans/js/i18n_tabs.js new file mode 100644 index 0000000..fa2280f --- /dev/null +++ b/modeltrans/static/modeltrans/js/i18n_tabs.js @@ -0,0 +1,198 @@ +(() => { + const currentLanguage = document.documentElement.lang; + + const addEventListeners = ({ defaultField, fields }) => { + const parent = fields[0].closest(".i18n-tabs"); + + fields.forEach((field) => { + const fieldLanguage = field.dataset.i18nLang; + + if (field.dataset.i18nDefault) { + // Synchronize value with the default field + field.addEventListener("input", () => { + defaultField.value = field.value; + }); + } + + const tabButton = parent.querySelector( + `.i18n-button[data-i18n-lang=${fieldLanguage}]` + ); + tabButton.addEventListener("click", () => { + document.querySelectorAll(".i18n-button").forEach((button) => { + if (button.dataset.i18nLang === fieldLanguage) { + button.classList.add("active"); + } else { + button.classList.remove("active"); + } + }); + document.querySelectorAll(".i18n-tab").forEach((tab) => { + if (tab.dataset.i18nLang === fieldLanguage) { + tab.classList.remove("hidden"); + } else { + tab.classList.add("hidden"); + } + }); + + field.focus(); + field.selectionStart = field.value.length; + }); + }); + }; + + document.addEventListener("DOMContentLoaded", () => { + const i18nFields = document.querySelectorAll("[data-i18n-field]"); + const fieldGroups = {}; + + i18nFields.forEach((field) => { + let formset, formsetIndex; + let groupName = field.dataset.i18nField; + + // Check if we're in a formset + const formsetsContainer = field.closest("[data-inline-formset]"); + if ( + formsetsContainer && + !formsetsContainer.dataset.formsetListener + ) { + formsetsContainer.addEventListener("formset:added", (event) => { + const formsetContainer = event.target; + const formsetIndex = event.target.id.match(/\d+/)[0]; + + const templateGroup = fieldGroups[groupName]; + const newGroupName = groupName.replace( + "__prefix__", + formsetIndex + ); + + fieldGroups[newGroupName] = { + defaultField: formsetContainer.querySelector( + `[name=${newGroupName}]` + ), + fields: templateGroup.fields.map((field) => { + return formsetContainer.querySelector( + `[name=${field.name.replace( + "__prefix__", + formsetIndex + )}]` + ); + }), + isTemplate: false, + }; + + // Re-run the script to add tabs to the new formset + const { defaultField, fields } = fieldGroups[newGroupName]; + addEventListeners({ defaultField, fields }); + }); + formsetsContainer.dataset.formsetListener = true; + } + + if (formsetsContainer) { + formset = JSON.parse(formsetsContainer.dataset.inlineFormset); + // Get current index + const formsetContainer = field.closest( + `[id^=${formset.options.prefix}]` + ); + + if (formsetContainer.id == `${formset.options.prefix}-empty`) { + formsetIndex = "__prefix__"; + } else { + formsetIndex = formsetContainer.id.match(/\d+/)[0]; + } + groupName = `${formset.options.prefix}-${formsetIndex}-${field.dataset.i18nField}`; + } + + if (!fieldGroups[groupName]) { + let selector = `[name=${groupName}]`; + const defaultField = field + .closest(".form-multiline") + ?.querySelector(selector); + + fieldGroups[groupName] = { + defaultField: defaultField, + fields: [], + isTemplate: formsetIndex === "__prefix__", + }; + } + fieldGroups[groupName].fields.push(field); + }); + + for (const group in fieldGroups) { + const { defaultField, fields, isTemplate } = fieldGroups[group]; + const parent = fields[0].closest(".form-multiline"); + + let errorlist = null; + let helptext = null; + if (defaultField) { + const groupLabel = document.createElement("label"); + groupLabel.textContent = defaultField.labels[0].textContent; + errorlist = parent.querySelector(".errorlist"); + + if (defaultField.required) { + groupLabel.classList.add("required"); + } + if (defaultField.hasAttribute("aria-describedby")) { + helptext = document.getElementById( + defaultField.getAttribute("aria-describedby") + ); + } + parent.replaceChildren(groupLabel); + } else { + console.error(`Error setting up tabs for ${group}, aborting.`); + continue; + } + + const tabs = document.createElement("div"); + tabs.classList.add("i18n-tabs"); + + const tabButtons = document.createElement("div"); + tabButtons.classList.add("i18n-tab-buttons"); + tabs.appendChild(tabButtons); + + tabs.appendChild(defaultField); + defaultField.classList.add("hidden"); + + fields.forEach((field) => { + const fieldLanguage = field.dataset.i18nLang; + + const tabButton = document.createElement("button"); + tabButton.type = "button"; + tabButton.classList.add("i18n-button"); + tabButton.dataset.i18nLang = fieldLanguage; + tabButton.textContent = fieldLanguage; + + const tab = document.createElement("div"); + tab.classList.add("i18n-tab"); + tab.dataset.i18nLang = fieldLanguage; + if (defaultField.placeholder) { + field.placeholder = defaultField.placeholder; + } + tab.appendChild(field); + + if (field.dataset.i18nDefault && defaultField.ariaInvalid) { + tabButton.classList.add("errors"); + tab.classList.add("errors"); + } + tabButtons.appendChild(tabButton); + + // Enable the currently active language + if (fieldLanguage === currentLanguage) { + tabButton.classList.add("active"); + } else { + tab.classList.add("hidden"); + } + tabs.appendChild(tab); + }); + + if (!isTemplate) { + addEventListeners({ defaultField, fields }); + } + + parent.appendChild(tabs); + if (errorlist) { + parent.parentNode.insertBefore(errorlist, parent); + } + if (helptext) { + parent.parentNode.appendChild(helptext); + } + } + }); +})(); diff --git a/tests/app/admin.py b/tests/app/admin.py index 5555879..10395b1 100644 --- a/tests/app/admin.py +++ b/tests/app/admin.py @@ -1,8 +1,8 @@ from django.contrib import admin -from modeltrans.admin import ActiveLanguageMixin +from modeltrans.admin import ActiveLanguageMixin, TabbedLanguageMixin -from .models import Blog, Category, Site +from .models import Blog, Category, Post, Site @admin.register(Blog) @@ -19,3 +19,8 @@ class CategoryAdmin(ActiveLanguageMixin, admin.ModelAdmin): @admin.register(Site) class SiteAdmin(ActiveLanguageMixin, admin.ModelAdmin): pass + + +@admin.register(Post) +class PostAdmin(TabbedLanguageMixin, admin.ModelAdmin): + pass diff --git a/tests/test_admin.py b/tests/test_admin.py index b8a75d0..3297142 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -3,7 +3,7 @@ from django.urls import reverse from django.utils.translation import override -from .app.models import Category, Site +from .app.models import Category, Post, Site from .utils import load_wiki User = get_user_model() @@ -66,3 +66,15 @@ def url(q): response = self.client.get(url("frog")) self.assertContains(response, "Frog") + + def test_tabbed_admin(self): + post = Post.objects.create( + title="History of the Universe", + title_nl="Geschiedenis van het heelal", + ) + url = reverse("admin:app_post_change", args=(post.pk,)) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "History of the Universe") + self.assertContains(response, "Geschiedenis van het heelal")