From 82c3f041cec65ad822c883c3b10d109c65f6ee04 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sun, 17 May 2026 11:27:25 +0200 Subject: [PATCH] feat(modals): layout context processor for HX-Modal header Switches the Jinja base layout to _modal_partial.html (yield-only) when the HX-Modal:1 request header is set, otherwise base.html. Foundation for URL-addressable modals (spec 2026-05-17-url-addressable-modals). Guards with has_request_context() so the processor is safe when render_template_string is called from app_context() without a request (e.g. test_timeago_filter_registered_on_app). Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/l4d2web/app.py | 7 +++++ l4d2web/l4d2web/templates/_modal_partial.html | 1 + l4d2web/tests/test_url_addressable_modals.py | 29 +++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 l4d2web/l4d2web/templates/_modal_partial.html create mode 100644 l4d2web/tests/test_url_addressable_modals.py diff --git a/l4d2web/l4d2web/app.py b/l4d2web/l4d2web/app.py index 0fce842..faad17f 100644 --- a/l4d2web/l4d2web/app.py +++ b/l4d2web/l4d2web/app.py @@ -61,6 +61,13 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask: app.add_template_filter(format_time_html, "timeago") + @app.context_processor + def inject_base_layout() -> dict[str, str]: + from flask import has_request_context + + is_modal = has_request_context() and request.headers.get("HX-Modal") == "1" + return {"base_layout": "_modal_partial.html" if is_modal else "base.html"} + @app.before_request def csrf_protect() -> Response | None: if "csrf_token" not in session: diff --git a/l4d2web/l4d2web/templates/_modal_partial.html b/l4d2web/l4d2web/templates/_modal_partial.html new file mode 100644 index 0000000..cb0dbe4 --- /dev/null +++ b/l4d2web/l4d2web/templates/_modal_partial.html @@ -0,0 +1 @@ +{% block content %}{% endblock %} diff --git a/l4d2web/tests/test_url_addressable_modals.py b/l4d2web/tests/test_url_addressable_modals.py new file mode 100644 index 0000000..6fec021 --- /dev/null +++ b/l4d2web/tests/test_url_addressable_modals.py @@ -0,0 +1,29 @@ +from flask import render_template_string + +from l4d2web.app import create_app + + +def _make_app(tmp_path, monkeypatch, db_name: str): + db_url = f"sqlite:///{tmp_path/db_name}" + monkeypatch.setenv("DATABASE_URL", db_url) + return create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + + +def test_base_layout_is_modal_partial_when_hx_modal_header_set(tmp_path, monkeypatch): + app = _make_app(tmp_path, monkeypatch, "layout-modal.db") + with app.test_request_context("/", headers={"HX-Modal": "1"}): + assert render_template_string("{{ base_layout }}") == "_modal_partial.html" + + +def test_base_layout_is_base_html_for_normal_request(tmp_path, monkeypatch): + app = _make_app(tmp_path, monkeypatch, "layout-default.db") + with app.test_request_context("/"): + assert render_template_string("{{ base_layout }}") == "base.html" + + +def test_base_layout_does_not_react_to_plain_hx_request_header(tmp_path, monkeypatch): + # HTMX sets HX-Request on every request including the build-status poll; + # only HX-Modal should switch the layout. + app = _make_app(tmp_path, monkeypatch, "layout-hxreq.db") + with app.test_request_context("/", headers={"HX-Request": "true"}): + assert render_template_string("{{ base_layout }}") == "base.html"