Wagtail - How can I use a custom StructBlock with programmatically assignable default values?
I have created a StreamField for some of the site's settings. This StreamField contains a collection of a custom StructBlock, named FacetBlock. FacetBlock contains a BooleanBlock, a CharBlock, an IntegerBlock and a ChoiceBlock. Now I need to make the CharBlock translatable, which means the admin will be able to enter one text for each language available on the site.
The way I was thinking of doing this is by using another custom StructBlock, this time called TranslatableTextBlock. It would contain a ChoiceBlock, a CharBlock and some hidden input to store the complete value. Using javascript, the admin would select a language, enter the text for that language and then move on to the next language. I'm not sure yet how that would be saved the database, but I'm not even able to get that far.
Right now, I can have either the fields show up with no default value or I get a javascript error (TypeError: e is null
) in vendor.js
, which triggers the same error in comments.js
. Here is the code I have so far. I have omitted the FacetBlock definition, because it works perfectly when reverting TranslatableTextBlock to a CharBlock.
Python code:
class TranslatableTextBlock(StructBlock):
def __init__(self, default_text:str = None, default_language:str = None, local_blocks = None, search_index = True, **kwargs):
local_blocks = [
(
"language",
ChoiceBlock(
choices=LANGUAGES,
default=default_language if default_language else None,
help_text=_("Select the language for this text")
)
),
(
"text",
CharBlock(
default=default_text if default_text else None,
help_text=_("Enter your text in the selected language"),
)
)
]
super().__init__(local_blocks, search_index, **kwargs)
class Meta:
form_classname = "struct-block translatable-text-block"
form_template = 'blocks/translatable_text_block_admin.html'
icon = 'doc-full'
label = _('Translated text')
template = 'blocks/translatable_text_block.html'
class TranslatableTextBlockAdapter(StructBlockAdapter):
js_constructor = "website.blocks.TranslatableTextBlock"
@cached_property
def media(self):
structblock_media = super().media
return Media(
js=structblock_media._js + ["website/js/translatable_text_block.js"],
css=structblock_media._css
)
register(TranslatableTextBlockAdapter(), TranslatableTextBlock)
The admin template (translatable_text_block_admin.html):
{% load wagtailadmin_tags %}
<div class="{{ classname }}">
{% if help_text %}
<span>
<div class="help">
{% icon name="help" classname="default" %}
{{ help_text }}
</div>
</span>
{% endif %}
{% for child in children %}
{{ child }}
<div class="w-field" data-field data-contentpath="{{ child.block.name }}">
{% if child.block.label %}
<label class="w-field__label" {% if child.id_for_label %}for="{{ child.id_for_label }}"{% endif %}>{{ child.block.label }}{% if child.block.required %}<span class="w-required-mark">*</span>{% endif %}</label>
{% endif %}
{{ child.render_form }}
</div>
{% endfor %}
</div>
Javascript code (translatable_text_block.js):
if (typeof TranslatableTextBlockDefinition === "undefined") {
class TranslatableTextBlockDefinition extends window.wagtailStreamField.blocks.StructBlockDefinition {
render(placeholder, prefix, initialState, initialError) {
const block = super.render(placeholder, prefix, initialState, initialError); // The error happens on this line.
// Some custom modifications would happen here.
return block;
}
}
window.telepath.register("website.blocks.TranslatableTextBlock", TranslatableTextBlockDefinition);
}
The javascript error:
TypeError: e is null
vendor.js:2:191488
Uncaught TypeError: e is null
comments.js:1:47007
Is anyone familiar with nested StructBlock for Streamfields in admin settings? Or maybe I'm going at this the wrong way and there is an easier solution? I welcome frame challenges :)
Did you get this resolved?
I spun it up on a test site to see if I could spot the error as your code looked fine, it runs fine on my site:
The only difference I made was:
- commenting out the templates
- adding it to a page streamblock instead of a base setting
Neither of those should make any difference though.
Maybe the line reference is misreporting and the error comes from somewhere else in the adapter js code? Try chopping it back to the vanilla version you posted and, if that works, build out again from there to see what trips it up.
Incidentally, if you want to load the default language for your site, you can use Locale.get_default()
, but it needs to be done with a lazy query to avoid throwing an 'apps not ready' warning on the console:
from wagtail.models import Locale
from django.utils.functional import lazy
get_default_language = lazy(lambda: Locale.get_default().language_code, str)
class TranslatableTextBlock(StructBlock):
def __init__(self, default_text:str = None, default_language:str = None, local_blocks = None, search_index = True, **kwargs):
local_blocks = [
(
"language",
ChoiceBlock(
choices=settings.LANGUAGES,
default=default_language or get_default_language,
help_text=_("Select the language for this text")
)
),
(
"text",
CharBlock(
default=default_text or None,
help_text=_("Enter your text in the selected language"),
)
)
]
super().__init__(local_blocks, search_index, **kwargs)
Here is an example of using your TranslatableTextBlock
in a ListBlock
to store multiple language values and a template tag to pull the relevant value. I don't know the context that you use this setting in but the tag can be adapted easily enough to regular code if you have the ListBlock
value instance. the tag will return the data for the default language if none exists in the requested locale.
I've added some validation to make sure the default locale is included in the list block before being saved, and also that no locales have been repeated.
from django.conf import settings
from django.forms import Media, ValidationError
from django.forms.utils import ErrorList
from django.utils.functional import cached_property, lazy
from django.utils.translation import gettext_lazy as _
from wagtail.blocks import (CharBlock, ChoiceBlock, ListBlock,
ListBlockValidationError, StructBlock)
from wagtail.blocks.struct_block import StructBlockAdapter
from wagtail.models import Locale
from wagtail.telepath import register
get_default_language_code = lazy(lambda: Locale.get_default().language_code, str)
class TranslatableTextBlock(StructBlock):
def __init__(self, default_text: str = None, default_language: str = None, local_blocks=None, search_index=True, **kwargs):
local_blocks = [
(
"language",
ChoiceBlock(
choices=settings.LANGUAGES,
default=default_language or get_default_language_code,
help_text=_("Select the language for this text")
)
),
(
"text",
CharBlock(
default=default_text or None,
help_text=_("Enter your text in the selected language"),
)
)
]
super().__init__(local_blocks, search_index, **kwargs)
class Meta:
form_classname = "struct-block translatable-text-block"
# form_template = 'blocks/translatable_text_block_admin.html'
icon = 'doc-full'
label = _('Translated text')
class TranslatableTextBlockAdapter(StructBlockAdapter):
js_constructor = "website.blocks.TranslatableTextBlock"
@cached_property
def media(self):
structblock_media = super().media
return Media(
js=structblock_media._js + ["js/translatable_text_block.js"],
css=structblock_media._css
)
register(TranslatableTextBlockAdapter(), TranslatableTextBlock)
class TranslatableTextListBlock(ListBlock):
def __init__(self, **kwargs):
super().__init__(TranslatableTextBlock(), **kwargs)
def clean(self, value):
cleaned_value = super().clean(value)
default_locale = Locale.get_default()
seen_langs = set()
has_default = False
block_errors = {}
non_block_errors = ErrorList()
for index, item in enumerate(cleaned_value):
lang = item.get('language')
if lang in seen_langs:
block_errors[index] = ValidationError({
'language': [_("A text for this language has already been defined.")]
})
else:
seen_langs.add(lang)
if lang == default_locale.language_code:
has_default = True
if not has_default:
non_block_errors.append(
ValidationError(f"{_('At least one item must use the default site language')} {default_locale.language_name}.")
)
if block_errors or non_block_errors:
raise ListBlockValidationError(
block_errors=block_errors,
non_block_errors=non_block_errors
)
return cleaned_value
class Meta:
template = 'blocks/translatable_text_block.html'
#translatable_text_block_tags.py
import logging
from django import template
from django.utils.translation import get_language
from wagtail.models import Locale
register = template.Library()
@register.simple_tag(takes_context=True)
def translated_text(context, field_name='text'):
try:
request = context.get("request")
items = context.get('self').bound_blocks
current_lang = getattr(request, "LANGUAGE_CODE", None) or get_language()
# Try exact match
match = next(
(item for item in items if item.value.get("language", "") == current_lang),
None
)
# If not found, try base language (e.g. "es" from "es-mx") if locale includes region
if not match and "-" in current_lang:
base_lang = current_lang.split("-")[0]
match = next(
(item for item in items if item.value.get("language", "") == base_lang),
None
)
# If still not found, try default locale
if not match:
default_lang = Locale.get_default().language_code
match = next(
(item for item in items if item.value.get("language", "") == default_lang),
None
)
return match.value.get(field_name, "") if match else ""
except Exception as e:
logging.error(
f"{type(e).__name__} at line {e.__traceback__.tb_lineno} of {__file__}: {e}"
)
return ""
In the ListBlock
template, you'd call this with something like:
{% load translatable_text_block_tags %}
<p>{% translated_text %}</p>
If you wanted the list items to hold more than just a CharBlock, you can add as many blocks and then return a dictionary value back from the tag. The your template would be something like:
{% load translatable_text_block_tags %}
{% translated_text as translated %}
{{ translated.text }} ... {{ translated.url }} ... {{ translated.other_stuff }}
If all this is just for translating static text in templates without needing PO files, this article might be useful. With this method, you create static text collections, translate them with wagtail-localize (or other method), then call a template tag that pulls in the relevant translated collection:
{% get_template_set 'social' as trans %}
<h5><strong>{{ trans.login_title }}<strong></h5>
<p>{{ trans.login_prompt }}.</p>