Skip to content
Merged
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
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,15 +252,43 @@ 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, underline, bold, italic, strike, link,
# heading1, heading2, heading3, heading4,
# quote, code, bullet, number, indent, outdent, undo, redo.
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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
# <template id="trix-toolbar">…</template>.
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/<slug>/` to fetch per-form field metadata
(including available field types/widgets/choices) for frontend rendering.
- 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
Expand Down
35 changes: 30 additions & 5 deletions src/unfold_fobi/forms/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 "<trix-toolbar>" 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."""
Expand All @@ -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.
"""

Expand All @@ -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(
Expand Down Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions src/unfold_fobi/patches/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
Expand All @@ -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",
Expand Down
86 changes: 86 additions & 0 deletions src/unfold_fobi/patches/content_text_wysiwyg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""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.
"""

import functools

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__

@functools.wraps(original_init)
def patched_init(self, *args, **kwargs):
original_init(self, *args, **kwargs)
force_wysiwyg(self)

content_text_form_class.__init__ = patched_init
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

{# Render each visible field in a card with crispy forms styling #}
{% for field in form.visible_fields %}
<div class="form-row w-full mb-4" style="cursor: move;">
<div class="form-row w-full mb-4">
<div class="bg-white border border-base-200 rounded-default shadow-xs dark:bg-base-900 dark:border-base-800 w-full flex flex-col">
<div class="p-6 flex-grow">
{# This renders the field with Unfold crispy styling #}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{% comment %}
Renders <trix-toolbar> 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 }}

<input type="hidden" name="{{ widget.name }}" id="wysiwyg-{{ widget.name }}"{% if widget.value != None %} value="{{ widget.value }}"{% endif %}>

<div class="max-w-4xl relative">
<trix-toolbar id="trix-toolbar-{{ widget.name }}"></trix-toolbar>
<script>
(() => {
const source = document.querySelector('template[id="trix-toolbar"]');
const target = document.getElementById("trix-toolbar-{{ widget.name }}");
if (!source || !target || target.children.length > 0) return;
target.innerHTML = source.innerHTML;
source.remove();

const data = document.getElementById("{{ toolbar_buttons_id }}");
const allowed = data ? JSON.parse(data.textContent) : null;
if (!allowed) return;

const aliases = {
href: "link",
underlined: "underline",
increaseNestingLevel: "indent",
decreaseNestingLevel: "outdent",
};
const allowedSet = new Set(allowed);

target.querySelectorAll("button[data-trix-attribute], button[data-trix-action]").forEach((btn) => {
const raw = btn.dataset.trixAttribute || btn.dataset.trixAction;
if (!allowedSet.has(aliases[raw] || raw)) btn.remove();
});
target.querySelectorAll("[data-trix-button-group]").forEach((group) => {
if (!group.querySelector("button")) group.remove();
});
if (!allowedSet.has("link")) {
target.querySelectorAll("[data-trix-dialogs]").forEach((el) => el.remove());
}
})();
</script>

<trix-editor input="wysiwyg-{{ widget.name }}" toolbar="trix-toolbar-{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}></trix-editor>
</div>
Loading