From afd9fdd6c53bea27773b9da31102210cad90b6a8 Mon Sep 17 00:00:00 2001 From: Marc Widmer Date: Mon, 1 Jun 2026 14:24:02 +0200 Subject: [PATCH 1/4] feat: add WYSIWYG support for content_text plugin with customizable toolbar --- README.md | 32 ++++++- src/unfold_fobi/forms/widgets.py | 35 ++++++-- src/unfold_fobi/patches/__init__.py | 8 +- .../patches/content_text_wysiwyg.py | 83 +++++++++++++++++++ .../forms/wysiwyg_inline_toolbar.html | 47 +++++++++++ 5 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 src/unfold_fobi/patches/content_text_wysiwyg.py create mode 100644 src/unfold_fobi/templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html diff --git a/README.md b/README.md index 0c1d80f..986bbf4 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,35 @@ UNFOLD_FOBI_ALTCHA_CHALLENGE_EXPIRY = 300 # challenge TTL in seconds 4. Backend verifies the payload before processing the form submission. Missing, invalid, expired, or replayed payloads return `400`. -### 5) DRF notes +### 5) Content Text WYSIWYG editor + +The Fobi `content_text` plugin renders as an Unfold-styled Trix WYSIWYG editor +out of the box. Bleach is widened to keep Trix's output (`p`, `h1`–`h3`, `div`, +`s`, `u`, etc.) on save. No setup required. + +Optional settings (all unset = full upstream toolbar, no overrides): + +```python +# Restrict which buttons appear (subset of the names below). +# Available: p, underlined, bold, italic, strike, link, +# heading1, heading2, heading3, heading4, +# quote, code, bullet, number, indent, outdent, undo, redo. +UNFOLD_FOBI_CONTENT_TEXT_TOOLBAR_BUTTONS = [ + "bold", "italic", "link", "heading2", "heading3", "bullet", "number", +] + +# Ship fully custom toolbar markup. The partial must wrap its content in +# . +UNFOLD_FOBI_CONTENT_TEXT_TOOLBAR_TEMPLATE = "myproject/trix_toolbar.html" + +# Override the bleach allowlist used by ContentTextForm.clean_text. When set, +# the package leaves these alone; pair with the toolbar buttons you keep so +# saved markup matches what the editor can produce. +FOBI_PLUGIN_CONTENT_TEXT_ALLOWED_TAGS = ["a", "b", "br", "em", "li", "ol", "p", "strong", "ul"] +FOBI_PLUGIN_CONTENT_TEXT_ALLOWED_ATTRIBUTES = {"a": ["href", "title", "target", "rel"]} +``` + +### 6) DRF notes - Include both Fobi DRF URLs and `unfold_fobi.api.urls`. - Use `GET /api/fobi-form-fields//` to fetch per-form field metadata @@ -260,7 +288,7 @@ UNFOLD_FOBI_ALTCHA_CHALLENGE_EXPIRY = 300 # challenge TTL in seconds - Ensure each form has the `db_store` handler enabled for persisted API submissions. -### 6) Fobi AppConfig overrides (recommended for BigAutoField projects) +### 7) Fobi AppConfig overrides (recommended for BigAutoField projects) If your project uses `DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"`, replace the bare fobi entries in `INSTALLED_APPS` with the package-provided diff --git a/src/unfold_fobi/forms/widgets.py b/src/unfold_fobi/forms/widgets.py index 6c3d979..9530064 100644 --- a/src/unfold_fobi/forms/widgets.py +++ b/src/unfold_fobi/forms/widgets.py @@ -3,7 +3,9 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import Fieldset, Layout from django import forms +from django.conf import settings from django.utils.translation import gettext_lazy as _ +from unfold.contrib.forms.widgets import WysiwygWidget from unfold.widgets import ( UnfoldAdminCheckboxSelectMultiple, UnfoldAdminDateWidget, @@ -25,6 +27,29 @@ UnfoldBooleanSwitchWidget, ) +# Unfold's stock toolbar partial; override via UNFOLD_FOBI_CONTENT_TEXT_TOOLBAR_TEMPLATE. +DEFAULT_CONTENT_TEXT_TOOLBAR_TEMPLATE = "unfold/forms/helpers/toolbar.html" + + +class InlineToolbarWysiwygWidget(WysiwygWidget): + """WysiwygWidget that renders "" inline to bypass the + load-order race when Fobi's element-edit page renders "form.media" twice. + """ + + template_name = "unfold_fobi/forms/wysiwyg_inline_toolbar.html" + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + context["toolbar_template"] = getattr( + settings, + "UNFOLD_FOBI_CONTENT_TEXT_TOOLBAR_TEMPLATE", + DEFAULT_CONTENT_TEXT_TOOLBAR_TEMPLATE, + ) + buttons = getattr(settings, "UNFOLD_FOBI_CONTENT_TEXT_TOOLBAR_BUTTONS", None) + context["toolbar_buttons"] = list(buttons) if buttons is not None else None + context["toolbar_buttons_id"] = f"trix-toolbar-buttons-{name}" + return context + def _apply_field_help_texts(form_instance): """Add concise guidance for common Fobi element metadata fields.""" @@ -41,9 +66,9 @@ class _SplitDateTimeStringValueMixin: """ Ensure MultiWidget date/time inputs return a single string. - Django's ``DateTimeField`` expects a string, but ``SplitDateTimeWidget`` and - subclasses return a list. That causes ``DateTimeField.to_python`` to call - ``strip`` on a list and explode. We join non-empty parts so the field + Django's "DateTimeField" expects a string, but "SplitDateTimeWidget" and + subclasses return a list. That causes "DateTimeField.to_python" to call + "strip" on a list and explode. We join non-empty parts so the field receives the expected string value. """ @@ -63,7 +88,7 @@ def value_from_datadict(self, data, files, name): class UnfoldAdminSplitDateTimeWidgetCompat( _SplitDateTimeStringValueMixin, UnfoldAdminSplitDateTimeWidget ): - """Unfold split datetime widget that returns a string for Django's DateTimeField.""" + """Unfold "split datetime widget" that returns a string for Django's DateTimeField.""" class UnfoldAdminSplitDateTimeVerticalWidgetCompat( @@ -100,7 +125,7 @@ def apply_unfold_widgets_to_form(form_instance): _apply_field_help_texts(form_instance) def set_widget(field, widget_class): - """Replace widget while preserving attrs/choices when possible.""" + """Replace the widget while preserving attrs/choices when possible.""" old_widget = getattr(field, "widget", None) new_widget = widget_class() if isinstance(widget_class, type) else widget_class diff --git a/src/unfold_fobi/patches/__init__.py b/src/unfold_fobi/patches/__init__.py index 151e4ca..d4273df 100644 --- a/src/unfold_fobi/patches/__init__.py +++ b/src/unfold_fobi/patches/__init__.py @@ -1,10 +1,11 @@ """Monkey-patches applied to django-fobi at startup. -Each submodule exposes an idempotent ``apply()`` function called from -``UnfoldFobiConfig.ready()``. +Each submodule exposes an idempotent "apply()" function called from +"UnfoldFobiConfig.ready()". """ from .active_dates import apply as apply_active_dates +from .content_text_wysiwyg import apply as apply_content_text_wysiwyg from .owner_filtering import apply as apply_owner_filtering from .popup_response import apply as apply_popup_response from .widgets import apply as apply_widgets @@ -23,6 +24,8 @@ def _apply_altcha(): def apply_all(): """Apply every fobi patch in the correct order.""" apply_widgets() + # After apply_widgets so our wrappers nest on top of its BasePlugins. + apply_content_text_wysiwyg() apply_mail_sender() apply_owner_filtering() apply_popup_response() @@ -33,6 +36,7 @@ def apply_all(): __all__ = [ "apply_active_dates", "apply_all", + "apply_content_text_wysiwyg", "apply_mail_sender", "apply_owner_filtering", "apply_popup_response", diff --git a/src/unfold_fobi/patches/content_text_wysiwyg.py b/src/unfold_fobi/patches/content_text_wysiwyg.py new file mode 100644 index 0000000..5456eee --- /dev/null +++ b/src/unfold_fobi/patches/content_text_wysiwyg.py @@ -0,0 +1,83 @@ +"""WYSIWYG support for the Fobi ``content_text`` plugin. + +Swaps the default single-line widget for "InlineToolbarWysiwygWidget" and +widens the bleach allowlist so Trix's output survives sanitization. Must run +after "apply_widgets" so our wrappers go on top of its ones. +""" + +from django.conf import settings + +_TRIX_ALLOWED_TAGS = [ + "a", "abbr", "acronym", "b", "blockquote", "br", "code", "div", + "em", "h1", "h2", "h3", "i", "li", "ol", "p", "pre", "s", "strike", + "strong", "u", "ul", +] +_TRIX_ALLOWED_ATTRIBUTES = { + "a": ["href", "title", "target", "rel"], + "abbr": ["title"], + "acronym": ["title"], +} + + +def apply(): + """Install the content_text WYSIWYG swap — idempotent. + + Silently no-ops if the "content_text" plugin isn't installed. + """ + try: + from fobi import base as fobi_base + from fobi.contrib.plugins.form_elements.content.content_text import ( + forms as content_text_forms, + ) + except ImportError: + return + + from unfold_fobi.forms.widgets import InlineToolbarWysiwygWidget + + content_text_form_class = content_text_forms.ContentTextForm + + if getattr(content_text_form_class, "_wysiwyg_widget_applied", False): + return + + def force_wysiwyg(form): + if not isinstance(form, content_text_form_class): + return form + text_field = form.fields.get("text") + if text_field is not None and not isinstance( + text_field.widget, InlineToolbarWysiwygWidget + ): + text_field.widget = InlineToolbarWysiwygWidget() + return form + + # --- Patch ContentTextForm.__init__ for direct instantiation paths --- + original_init = content_text_form_class.__init__ + + def patched_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + force_wysiwyg(self) + + content_text_form_class.__init__ = patched_init + content_text_form_class._wysiwyg_widget_applied = True + # Sentinel that short-circuits apply_widgets' lazy __init__ re-wrap. + content_text_form_class._unfold_widgets_applied = True + + # --- Re-wrap BasePlugin.get_initialised_{edit,create}_form_or_404 --- + def wrap_method(cls, name): + orig = getattr(cls, name, None) + if orig is None: + return + + def wrapped(self, *args, **kwargs): + form = orig(self, *args, **kwargs) + return force_wysiwyg(form) if form is not None else form + + setattr(cls, name, wrapped) + + wrap_method(fobi_base.BasePlugin, "get_initialised_edit_form_or_404") + wrap_method(fobi_base.BasePlugin, "get_initialised_create_form_or_404") + + # --- Widen bleach allowlist unless the project already overrode it --- + if not hasattr(settings, "FOBI_PLUGIN_CONTENT_TEXT_ALLOWED_TAGS"): + content_text_forms.ALLOWED_TAGS = _TRIX_ALLOWED_TAGS + if not hasattr(settings, "FOBI_PLUGIN_CONTENT_TEXT_ALLOWED_ATTRIBUTES"): + content_text_forms.ALLOWED_ATTRIBUTES = _TRIX_ALLOWED_ATTRIBUTES diff --git a/src/unfold_fobi/templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html b/src/unfold_fobi/templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html new file mode 100644 index 0000000..ae8d304 --- /dev/null +++ b/src/unfold_fobi/templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html @@ -0,0 +1,47 @@ +{% comment %} +Renders inline beside the editor to bypass Trix.config +load-order races. Filters buttons by UNFOLD_FOBI_CONTENT_TEXT_TOOLBAR_BUTTONS +when set. +{% endcomment %} + +{% include toolbar_template %} +{{ toolbar_buttons|json_script:toolbar_buttons_id }} + + + +
+ + + + +
From 612fac56728d923845dca22b964ec9934c8a7ee6 Mon Sep 17 00:00:00 2001 From: Marc Widmer Date: Mon, 1 Jun 2026 14:31:01 +0200 Subject: [PATCH 2/4] fix: remove unnecessary cursor on form-rows --- .../override_simple_theme/snippets/form_edit_snippet.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/unfold_fobi/templates/override_simple_theme/snippets/form_edit_snippet.html b/src/unfold_fobi/templates/override_simple_theme/snippets/form_edit_snippet.html index ab72beb..e7999b4 100644 --- a/src/unfold_fobi/templates/override_simple_theme/snippets/form_edit_snippet.html +++ b/src/unfold_fobi/templates/override_simple_theme/snippets/form_edit_snippet.html @@ -26,7 +26,7 @@ {# Render each visible field in a card with crispy forms styling #} {% for field in form.visible_fields %} -
+
{# This renders the field with Unfold crispy styling #} From 8e346f7cce8616c9fe92eb1712dd95a28763d0db Mon Sep 17 00:00:00 2001 From: Marc Widmer Date: Mon, 1 Jun 2026 14:35:47 +0200 Subject: [PATCH 3/4] fix: correct "underlined" to "underline" in README and templates, and add `functools.wraps` to patch decorator --- README.md | 2 +- src/unfold_fobi/patches/content_text_wysiwyg.py | 3 +++ .../templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 986bbf4..453279f 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ Optional settings (all unset = full upstream toolbar, no overrides): ```python # Restrict which buttons appear (subset of the names below). -# Available: p, underlined, bold, italic, strike, link, +# Available: p, underline, bold, italic, strike, link, # heading1, heading2, heading3, heading4, # quote, code, bullet, number, indent, outdent, undo, redo. UNFOLD_FOBI_CONTENT_TEXT_TOOLBAR_BUTTONS = [ diff --git a/src/unfold_fobi/patches/content_text_wysiwyg.py b/src/unfold_fobi/patches/content_text_wysiwyg.py index 5456eee..28473f3 100644 --- a/src/unfold_fobi/patches/content_text_wysiwyg.py +++ b/src/unfold_fobi/patches/content_text_wysiwyg.py @@ -5,6 +5,8 @@ after "apply_widgets" so our wrappers go on top of its ones. """ +import functools + from django.conf import settings _TRIX_ALLOWED_TAGS = [ @@ -52,6 +54,7 @@ def force_wysiwyg(form): # --- Patch ContentTextForm.__init__ for direct instantiation paths --- original_init = content_text_form_class.__init__ + @functools.wraps(original_init) def patched_init(self, *args, **kwargs): original_init(self, *args, **kwargs) force_wysiwyg(self) diff --git a/src/unfold_fobi/templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html b/src/unfold_fobi/templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html index ae8d304..7362098 100644 --- a/src/unfold_fobi/templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html +++ b/src/unfold_fobi/templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html @@ -25,6 +25,7 @@ const aliases = { href: "link", + underlined: "underline", increaseNestingLevel: "indent", decreaseNestingLevel: "outdent", }; From 8ff3225ebeedd75212409d8680bf2b583f9d83ff Mon Sep 17 00:00:00 2001 From: Marc Widmer Date: Mon, 1 Jun 2026 14:43:14 +0200 Subject: [PATCH 4/4] fix: remove redundant attribute included from the WYSIWYG toolbar template --- .../templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/unfold_fobi/templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html b/src/unfold_fobi/templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html index 7362098..aed8141 100644 --- a/src/unfold_fobi/templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html +++ b/src/unfold_fobi/templates/unfold_fobi/forms/wysiwyg_inline_toolbar.html @@ -7,7 +7,7 @@ {% include toolbar_template %} {{ toolbar_buttons|json_script:toolbar_buttons_id }} - +