Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tabbed translation admin #109

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

4 changes: 2 additions & 2 deletions example/app/admin.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")

Expand Down
69 changes: 69 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,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
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: 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;
}
198 changes: 198 additions & 0 deletions modeltrans/static/modeltrans/js/i18n_tabs.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
});
})();
julianwachholz marked this conversation as resolved.
Show resolved Hide resolved
9 changes: 7 additions & 2 deletions tests/app/admin.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
Loading
Loading