Skip to content

Commit

Permalink
Add tabbed translation admin
Browse files Browse the repository at this point in the history
  • Loading branch information
julianwachholz committed Jun 26, 2024
1 parent e4a2b8a commit 7918d3a
Show file tree
Hide file tree
Showing 4 changed files with 321 additions and 0 deletions.
23 changes: 23 additions & 0 deletions docs/pages/admin.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand All @@ -19,3 +41,4 @@ the currently active language. Use like this::
@admin.register(Blog)
class BlogAdmin(ActiveLanguageMixin, admin.ModelAdmin):
pass

74 changes: 74 additions & 0 deletions modeltrans/admin.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -33,3 +34,76 @@ 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
40 changes: 40 additions & 0 deletions modeltrans/static/modeltrans/css/i18n_tabs.css
Original file line number Diff line number Diff line change
@@ -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;
}
184 changes: 184 additions & 0 deletions modeltrans/static/modeltrans/js/i18n_tabs.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
});

})();

0 comments on commit 7918d3a

Please sign in to comment.