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/modeltrans/admin.py b/modeltrans/admin.py index 80450ee..d061742 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,74 @@ def get_exclude(self, request, obj=None): # de-duplicate return list(set(excludes)) + + +class TabbedLanguageMixin: + """ + Mixin for your ModelAdmin to show a tabbed interface for i18n fields. + + """ + + 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..fa4e8f1 --- /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: 10px; + 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..1c3ab91 --- /dev/null +++ b/modeltrans/static/modeltrans/js/i18n_tabs.js @@ -0,0 +1,184 @@ +(() => { + const currentLanguage = document.documentElement.lang; + + const addEventListeners = ({ defaultField, fields }) => { + console.info(`Setting up event listeners on fields`, 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', () => { + console.log(`Synchronizing value of ${field.name} with default field`); + 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) => { + console.log(`new formset added for group ${groupName}`); + + 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'); + //tab.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); + } + } + }); + +})();