diff --git a/.gitignore b/.gitignore index e675e09a..58252718 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ src/reactpy_django/static/reactpy_django/index.js src/reactpy_django/static/reactpy_django/index.js.map src/reactpy_django/static/reactpy_django/pyscript src/reactpy_django/static/reactpy_django/morphdom +tmp/* # Django # logs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8526d721..c07a5fa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Don't forget to remove deprecated code on each major release! ### Added - Automatically serve ReactPy wheel from Django's static directory when using PyScript. +- Jinja2 template support via `reactpy_django.templatetags.jinja.ReactPyExtension`. ### Changed diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index bde5ac08..5537356c 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -2,7 +2,9 @@ nav: - Home: index.md - Get Started: - - Add ReactPy to a Django Project: learn/add-reactpy-to-a-django-project.md + - Add ReactPy to your...: + - Django Project: learn/add-reactpy-to-a-django-project.md + - Django Project (with Jinja): learn/add-reactpy-to-a-django-project-with-jinja.md - Your First Component: learn/your-first-component.md - Reference: - Components: reference/components.md diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index d2ff722d..889bd62d 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -49,3 +49,8 @@ linters linting formatters bootstrap_form +env +jinja +jinja2 +myproject +vs diff --git a/docs/src/learn/add-reactpy-to-a-django-project-with-jinja.md b/docs/src/learn/add-reactpy-to-a-django-project-with-jinja.md new file mode 100644 index 00000000..0464c1c4 --- /dev/null +++ b/docs/src/learn/add-reactpy-to-a-django-project-with-jinja.md @@ -0,0 +1,216 @@ +## Overview + +

+ +If you use **Jinja2 templates** in your Django project and want to add ReactPy interactivity, this guide walks you through the additional configuration needed. First complete the [standard setup](./add-reactpy-to-a-django-project.md), then follow the steps below. + +

+ +!!! abstract "Note" + + These docs assume you have already completed the [standard ReactPy-Django setup](./add-reactpy-to-a-django-project.md) and have a working **Django project** with Jinja2 configured. + +--- + +## Step 1: Install from PyPI + +Run the following command to install [`reactpy-django`](https://pypi.org/project/reactpy-django/) and [`jinja2`](https://pypi.org/project/Jinja2/) in your Python environment. + +```bash linenums="0" +pip install reactpy-django jinja2 +``` + +## Step 2: Configure `settings.py` + +Add `#!python "reactpy_django"` to [`INSTALLED_APPS`](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-INSTALLED_APPS) in your [`settings.py`](https://docs.djangoproject.com/en/stable/topics/settings/) file. + +=== "settings.py" + + ```python + {% include "../../examples/python/configure_installed_apps.py" %} + ``` + +??? warning "Enable ASGI and Django Channels (Required)" + + ReactPy-Django requires Django ASGI and [Django Channels](https://github.com/django/channels) WebSockets. + + If you have not enabled ASGI on your **Django project** yet, here is a summary of the [`django`](https://docs.djangoproject.com/en/stable/howto/deployment/asgi/) and [`channels`](https://channels.readthedocs.io/en/stable/installation.html) installation docs: + + 1. Install `channels[daphne]` + 2. Add `#!python "daphne"` to `#!python INSTALLED_APPS`. + + ```python linenums="0" + {% include "../../examples/python/configure_channels_installed_app.py" %} + ``` + + 3. Set your `#!python ASGI_APPLICATION` variable. + + ```python linenums="0" + {% include "../../examples/python/configure_channels_asgi_app.py" %} + ``` + +??? info "Configure ReactPy settings (Optional)" + + ReactPy's has additional configuration available to fit a variety of use cases. + + See the [ReactPy settings](../reference/settings.md) documentation to learn more. + +Also add a Jinja2 backend entry to your `TEMPLATES` list: + +The backend must include: + +- `"BACKEND": "django.template.backends.jinja2.Jinja2"` +- An `"environment"` option pointing to a function that registers the `ReactPyExtension` + +=== "settings.py" + + ```python + import os + + TEMPLATES = [ + ... , + { + "BACKEND": "django.template.backends.jinja2.Jinja2", + "DIRS": [os.path.join(BASE_DIR, "templates")], + "OPTIONS": { + "environment": "myproject.jinja_env.environment", + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, + ] + ``` + +## Step 3: Configure `urls.py` + +Add ReactPy HTTP paths to your `#!python urlpatterns` in your [`urls.py`](https://docs.djangoproject.com/en/stable/topics/http/urls/) file. + +=== "urls.py" + + ```python + {% include "../../examples/python/configure_urls.py" %} + ``` + +## Step 4: Configure `asgi.py` + +Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [`asgi.py`](https://docs.djangoproject.com/en/stable/howto/deployment/asgi/) file. + +=== "asgi.py" + + ```python + {% include "../../examples/python/configure_asgi.py" %} + ``` + +??? info "Add `#!python AuthMiddlewareStack` (Optional)" + + There are many situations where you need to access the Django `#!python User` or `#!python Session` objects within ReactPy components. For example, if you want to: + + 1. Access the `#!python User` that is currently logged in + 3. Access Django's `#!python Session` object + 2. Login or logout the current `#!python User` + + In these situations will need to ensure you are using `#!python AuthMiddlewareStack`. + + {% include "../../includes/auth-middleware-stack.md" %} + +??? question "Where is my `asgi.py`?" + + If you do not have an `asgi.py`, follow the [`channels` installation guide](https://channels.readthedocs.io/en/stable/installation.html). + +## Step 5: Register the Jinja2 Extension + +Create a `jinja_env.py` module that registers the `ReactPyExtension`: + +=== "myproject/jinja_env.py" + + ```python + from jinja2 import Environment + from reactpy_django.templatetags.jinja import ReactPyExtension + + + def environment(**options): + env = Environment(**options) + env.add_extension(ReactPyExtension) + return env + ``` + +## Step 6: Run database migrations + +Run Django's [`migrate` command](https://docs.djangoproject.com/en/stable/topics/migrations/) to initialize ReactPy-Django's database table. + +```bash linenums="0" +python manage.py migrate +``` + +## Step 7: Check your configuration + +Run Django's [`check` command](https://docs.djangoproject.com/en/stable/ref/django-admin/#check) to verify everything is set up correctly. + +```bash linenums="0" +python manage.py check +``` + +## Step 8: Create your first component + +The [next page](./your-first-component.md) will show you how to create your first ReactPy component. + +Prefer a quick summary? Read the **At a Glance** section below. + +!!! info "At a Glance" + + With the extension registered, you can call ReactPy functions directly inside any Jinja2 template: + + === "templates/example.jinja" + + ```jinja + + + + ReactPy + Jinja2 + + +

Server-side component

+ {{ component("my_app.components.hello_world", recipient="World") }} + +

Client-side PyScript component

+ {{ pyscript_component("my_app/components/my_app.py") }} + + {{ pyscript_setup() }} + + + ``` + + --- + + **`my_app/components.py`** + + {% include-markdown "../../../README.md" start="" end="" %} + + --- + + **`my_app/templates/my_template.jinja`** + + In your Jinja2 template, call the `component` function directly with the dotted path to your component: + + ```jinja + + + + {{ component("my_app.components.hello_world", recipient="World") }} + + + ``` + + ??? info "Template tag vs Jinja2 function syntax" + + Unlike Django templates which require `{% load reactpy %}` and `{% component "..." %}`, Jinja2 allows you to call component functions directly using the `{{ component(...) }}` syntax. The registered functions are: + + | Function | Description | + |---|---| + | `{{ component(dotted_path, *args, **kwargs) }}` | Render a server-side ReactPy component. Equivalent to `{% component ... %}`. | + | `{{ pyscript_component(*file_paths, initial, root) }}` | Render a client-side PyScript component. Equivalent to `{% pyscript_component ... %}`. | + | `{{ pyscript_setup(*extra_py, extra_js, config) }}` | Render PyScript setup configuration. Equivalent to `{% pyscript_setup ... %}`. | diff --git a/pyproject.toml b/pyproject.toml index 42e1b1f0..abdc6be5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,7 @@ extra-dependencies = [ "django-bootstrap5", "decorator", "uvicorn[standard]", + "jinja2", ] matrix-name-format = "{variable}-{value}" @@ -197,7 +198,7 @@ deploy_develop = ["cd docs && mike deploy --push develop"] ################################ [tool.hatch.envs.python] -extra-dependencies = ["django-stubs", "channels-redis", "pyright"] +extra-dependencies = ["django-stubs", "channels-redis", "pyright", "jinja2"] [tool.hatch.envs.python.scripts] type_check = ["pyright src"] diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index d778e531..cef8ec37 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -83,9 +83,19 @@ def reactpy_warnings(app_configs, **kwargs): ) # Check if the reactpy/component.html template exists - try: + template_found = False + with contextlib.suppress(Exception): loader.get_template("reactpy/component.html") - except Exception: + template_found = True + if not template_found: + from django.template import engines as _engines + + for _engine in _engines.all(): + with contextlib.suppress(Exception): + _engine.get_template("reactpy/component.html") + template_found = True + break + if not template_found: warnings.append( checks.Warning( "ReactPy HTML templates could not be found!", @@ -251,6 +261,21 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + # Check if Jinja2 template backends are missing the request context processor + for _tmpl in getattr(settings, "TEMPLATES", []): + if "jinja2" in _tmpl.get("BACKEND", "").lower(): + _options = _tmpl.get("OPTIONS", {}) + _context_processors = _options.get("context_processors", []) + if "django.template.context_processors.request" not in _context_processors: + warnings.append( + checks.Warning( + "A Jinja2 template backend is missing the request context processor.", + hint="Add 'django.template.context_processors.request' to the context_processors " + "list within the OPTIONS of your Jinja2 template backend in settings.py:TEMPLATES.", + id="reactpy_django.W022", + ) + ) + return warnings diff --git a/src/reactpy_django/templatetags/jinja.py b/src/reactpy_django/templatetags/jinja.py new file mode 100644 index 00000000..b97bd55f --- /dev/null +++ b/src/reactpy_django/templatetags/jinja.py @@ -0,0 +1,222 @@ +""" +Jinja2 template support for ReactPy-Django. + +Provides Jinja2 global functions that mirror the functionality of Django's +``{% component %}``, ``{% pyscript_component %}``, and ``{% pyscript_setup %}`` +template tags. These are registered as environment globals so they can be +called directly from Jinja2 templates via ``{{ component(...) }}`` syntax. + +To enable, add the extension to your Jinja2 environment configuration: + +.. code-block:: python + + TEMPLATES = [ + { + "BACKEND": "django.template.backends.jinja2.Jinja2", + "DIRS": [...], + "OPTIONS": { + "environment": "myproject.jinja_env.environment", + }, + }, + ] + +Then in ``myproject/jinja_env.py``: + +.. code-block:: python + + from jinja2 import Environment + from reactpy_django.templatetags.jinja import ReactPyExtension + + + def environment(**options): + env = Environment(**options) + env.add_extension(ReactPyExtension) + return env +""" + +from __future__ import annotations + +try: + import jinja2 # noqa: F401 +except ModuleNotFoundError as e: + raise ModuleNotFoundError( + "The `jinja2` package is required to use ReactPy-Django's Jinja2 template support. " + "Install it with: pip install jinja2" + ) from e + +from logging import getLogger +from typing import TYPE_CHECKING + +from django.template import RequestContext, loader +from jinja2 import pass_context +from jinja2.ext import Extension + +from reactpy_django.templatetags.reactpy import ( + COMPONENT_TEMPLATE, + PYSCRIPT_COMPONENT_TEMPLATE, + PYSCRIPT_SETUP_TEMPLATE, +) +from reactpy_django.templatetags.reactpy import ( + component as django_component_tag, +) +from reactpy_django.templatetags.reactpy import ( + pyscript_component as django_pyscript_component_tag, +) +from reactpy_django.templatetags.reactpy import ( + pyscript_setup as django_pyscript_setup_tag, +) + +if TYPE_CHECKING: + from jinja2.runtime import Context + from reactpy.types import Component, VdomDict + +_logger = getLogger(__name__) + + +class ReactPyExtension(Extension): + """A Jinja2 extension that adds ReactPy component rendering functions. + + This extension registers the following globals into the Jinja2 environment: + + * ``component`` - Renders a server-side ReactPy component. + * ``pyscript_component`` - Renders a client-side PyScript component. + * ``pyscript_setup`` - Renders PyScript setup configuration. + + Unlike Django's template tags, which require ``{% load reactpy %}`` and use + ``{% component %}`` syntax, Jinja2 uses ``{{ component(...) }}`` function calls. + This is because Jinja2 has more expressive power and can directly handle + function expansions. + """ + + def __init__(self, environment): + super().__init__(environment) + environment.globals["component"] = self._component + environment.globals["pyscript_component"] = self._pyscript_component + environment.globals["pyscript_setup"] = self._pyscript_setup + + @pass_context + def _component( + self, + jinja_context: Context, + dotted_path: str, + *args, + host: str | None = None, + prerender: str = "", + offline: str = "", + **kwargs, + ) -> str: + """Render a server-side ReactPy component. + + This is the Jinja2 equivalent of ``{% component "path.to.Component" %}``. + + Args: + dotted_path: The dotted path to the component to render. + *args: Positional arguments to pass to the component. + host: The host to use for ReactPy connections. + prerender: If ``"true"`` the component will be pre-rendered server-side. + offline: Dotted path to an offline fallback component. + **kwargs: Keyword arguments to pass to the component. + + Returns: + Rendered HTML string. + """ + from reactpy_django.config import REACTPY_PRERENDER as _REACTPY_PRERENDER + + if not prerender: + prerender = str(_REACTPY_PRERENDER) + request = jinja_context.parent.get("request") + if request is None: + _logger.error( + "Cannot render a ReactPy component in a Jinja2 template without a " + "request object. Ensure the 'django.template.context_processors.request' " + "context processor is enabled for your Jinja2 backend." + ) + return "" + + django_context = RequestContext( + request, + autoescape=jinja_context.eval_ctx.autoescape, + ) + template_context = django_component_tag( + django_context, + dotted_path, + *args, + host=host, + prerender=prerender, + offline=offline, + **kwargs, + ) + return loader.render_to_string( + COMPONENT_TEMPLATE, + template_context, + request, + ) + + @pass_context + def _pyscript_component( + self, + jinja_context: Context, + *file_paths: str, + initial: str | VdomDict | Component = "", + root: str = "root", + ) -> str: + """Render a client-side PyScript component. + + This is the Jinja2 equivalent of ``{% pyscript_component "path/to/file.py" %}``. + + Args: + file_paths: File paths to client-side component Python files. + initial: Initial HTML displayed before the PyScript component loads. + root: The name of the root component function. + + Returns: + Rendered HTML string. + """ + request = jinja_context.parent.get("request") + if request is None: + _logger.error("Cannot render a PyScript component in a Jinja2 template without a request object.") + return "" + + django_context = RequestContext( + request, + autoescape=jinja_context.eval_ctx.autoescape, + ) + template_context = django_pyscript_component_tag( + django_context, + *file_paths, + initial=initial, + root=root, + ) + return loader.render_to_string( + PYSCRIPT_COMPONENT_TEMPLATE, + template_context, + request, + ) + + def _pyscript_setup( + self, + *extra_py: str, + extra_js: str | dict = "", + config: str | dict = "", + ) -> str: + """Render PyScript setup configuration. + + This is the Jinja2 equivalent of ``{% pyscript_setup %}``. + + Args: + extra_py: Additional Python dependencies. + extra_js: Additional JavaScript modules. + config: PyScript configuration overrides. + + Returns: + Rendered HTML string. + """ + template_context = django_pyscript_setup_tag( + *extra_py, + extra_js=extra_js, + config=config, + ) + return loader.render_to_string( + PYSCRIPT_SETUP_TEMPLATE, + template_context, + ) diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 77a07955..c6cd67ab 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -45,8 +45,12 @@ RESOLVED_WEB_MODULES_PATH = "" _logger.exception("Could not resolve the 'web_modules' URL path!") +COMPONENT_TEMPLATE = "reactpy/component.html" +PYSCRIPT_COMPONENT_TEMPLATE = "reactpy/pyscript_component.html" +PYSCRIPT_SETUP_TEMPLATE = "reactpy/pyscript_setup.html" -@register.inclusion_tag("reactpy/component.html", takes_context=True) + +@register.inclusion_tag(COMPONENT_TEMPLATE, takes_context=True) def component( context: template.RequestContext, dotted_path: str, @@ -190,7 +194,7 @@ def component( } -@register.inclusion_tag("reactpy/pyscript_component.html", takes_context=True) +@register.inclusion_tag(PYSCRIPT_COMPONENT_TEMPLATE, takes_context=True) def pyscript_component( context: template.RequestContext, *file_paths: str, @@ -224,7 +228,7 @@ def pyscript_component( } -@register.inclusion_tag("reactpy/pyscript_setup.html") +@register.inclusion_tag(PYSCRIPT_SETUP_TEMPLATE) def pyscript_setup( *extra_py: str, extra_js: str | dict = "", diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 38b7fa66..ba637e6a 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -62,6 +62,15 @@ + rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?" + r"\s*%}" ) +JINJA_COMPONENT_REGEX = re.compile( + r"\{\{\s*" + + _TAG_PATTERN + + r"\s*\(" + + r"\s*" + + _PATH_PATTERN + + rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?" + + r"\s*\)\s*\}\}" +) FILE_ASYNC_ITERATOR_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-FileAsyncIterator") SYNC_LAYOUT_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-SyncLayout") @@ -179,11 +188,19 @@ def get_paths(self) -> set[str]: if get_template_sources is None: get_template_sources = loader.get_template_sources paths.update(smart_str(origin) for origin in get_template_sources("")) + # Collect directories from template engines that don't expose + # Django-style loaders (e.g., Jinja2), by reading template_dirs directly. + from django.template.backends.base import BaseEngine + + for e in engines.all(): + if not hasattr(e, "engine") and isinstance(e, BaseEngine): + for directory in e.template_dirs: + paths.add(smart_str(directory)) return paths def get_templates(self, paths: set[str]) -> set[str]: - """Obtains a set of all HTML template paths.""" - extensions = [".html"] + """Obtains a set of all HTML and Jinja2 template paths.""" + extensions = [".html", ".jinja"] templates: set[str] = set() for path in paths: for root, _, files in os.walk(path, followlinks=False): @@ -201,7 +218,8 @@ def get_components(self, templates: set[str]) -> set[str]: for template in templates: with contextlib.suppress(Exception), open(template, encoding="utf-8") as template_file: clean_template = COMMENT_REGEX.sub("", template_file.read()) - regex_iterable = COMPONENT_REGEX.finditer(clean_template) + regex_iterable = list(COMPONENT_REGEX.finditer(clean_template)) + regex_iterable.extend(JINJA_COMPONENT_REGEX.finditer(clean_template)) new_components: list[str] = [] for match in regex_iterable: new_components.append(match.group("path").replace('"', "").replace("'", "")) diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index df44ad6c..cccc67a5 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -5,15 +5,20 @@ # Make sure the JS is always re-built before running the tests js_dir = Path(__file__).parent.parent.parent / "src" / "js" static_dir = Path(__file__).parent.parent.parent / "src" / "reactpy_django" / "static" / "reactpy_django" -assert subprocess.run(["bun", "install"], cwd=str(js_dir), check=True).returncode == 0 -assert ( + +# Check if bun is available; if so, rebuild the JS. If not, assume artifacts exist. +_bun_available = shutil.which("bun") is not None +if _bun_available: + subprocess.run(["bun", "install"], cwd=str(js_dir), check=True) subprocess.run( ["bun", "build", "./src/index.ts", f"--outdir={static_dir}", "--sourcemap=linked"], cwd=str(js_dir), check=True, - ).returncode - == 0 -) + ) +# Verify that JS artifacts already exist so we don't silently skip a needed build +elif not (static_dir / "index.js").exists(): + msg = f"bun is not installed and JS artifacts are missing. Run 'bun install && bun build' in {js_dir} first." + raise RuntimeError(msg) # Make sure the test environment is always using the latest JS diff --git a/tests/test_app/jinja2_templates/base.html b/tests/test_app/jinja2_templates/base.html new file mode 100644 index 00000000..90aa4068 --- /dev/null +++ b/tests/test_app/jinja2_templates/base.html @@ -0,0 +1,97 @@ + + + + + + + + + ReactPy + + + + +

ReactPy Test Page (Jinja2)

+
+ {{ component("test_app.components.hello_world", class="hello-world") }} +
+ {{ component("test_app.components.button", class="button") }} +
+ {{ component("test_app.components.parameterized_component", class="parametarized-component", x=123, y=456) }} +
+ {{ component("test_app.components.object_in_templatetag", my_object) }} +
+ {{ component("test_app.components.button_from_js_module") }} +
+ {{ component("test_app.components.use_connection") }} +
+ {{ component("test_app.components.use_scope") }} +
+ {{ component("test_app.components.use_location") }} +
+ {{ component("test_app.components.use_origin") }} +
+ {{ component("test_app.components.django_css") }} +
+ {{ component("test_app.components.django_js") }} +
+ {{ component("test_app.components.unauthorized_user") }} +
+ {{ component("test_app.components.authorized_user") }} +
+ {{ component("test_app.components.relational_query") }} +
+ {{ component("test_app.components.async_relational_query") }} +
+ {{ component("test_app.components.todo_list") }} +
+ {{ component("test_app.components.async_todo_list") }} +
+ {{ component("test_app.components.view_to_component_sync_func") }} +
+ {{ component("test_app.components.view_to_component_async_func") }} +
+ {{ component("test_app.components.view_to_component_sync_class") }} +
+ {{ component("test_app.components.view_to_component_async_class") }} +
+ {{ component("test_app.components.view_to_component_template_view_class") }} +
+ {{ component("test_app.components.view_to_component_script") }} +
+ {{ component("test_app.components.view_to_component_request") }} +
+ {{ component("test_app.components.view_to_component_args") }} +
+ {{ component("test_app.components.view_to_component_kwargs") }} +
+ {{ component("test_app.components.view_to_iframe_sync_func") }} +
+ {{ component("test_app.components.view_to_iframe_async_func") }} +
+ {{ component("test_app.components.view_to_iframe_sync_class") }} +
+ {{ component("test_app.components.view_to_iframe_async_class") }} +
+ {{ component("test_app.components.view_to_iframe_template_view_class") }} +
+ {{ component("test_app.components.view_to_iframe_args") }} +
+ {{ component("test_app.components.use_user_data") }} +
+ {{ component("test_app.components.use_user_data_with_default") }} +
+ {{ component("test_app.components.use_auth") }} +
+ {{ component("test_app.components.use_auth_no_rerender") }} +
+ {{ component("test_app.components.use_rerender") }} +
+ + + diff --git a/tests/test_app/jinja2_templates/errors.html b/tests/test_app/jinja2_templates/errors.html new file mode 100644 index 00000000..f9e5d17c --- /dev/null +++ b/tests/test_app/jinja2_templates/errors.html @@ -0,0 +1,34 @@ + + + + + + + + + ReactPy + + + +

ReactPy Errors Test Page (Jinja2)

+
+
{{ component("test_app.components.does_not_exist") }}
+
+
{{ component("test_app.components.hello_world", invalid_param="random_value") }}
+
+
{{ component("test_app.components.hello_world", host="https://example.com/") }}
+
+
+ {{ component("test_app.components.broken_postprocessor_query") }} +
+
+
+ {{ component("test_app.components.view_to_iframe_not_registered") }} +
+
+
+ {{ component("test_app.components.incorrect_user_passes_test_decorator") }}
+
+ + + diff --git a/tests/test_app/jinja2_templates/host_port.html b/tests/test_app/jinja2_templates/host_port.html new file mode 100644 index 00000000..bbd83e48 --- /dev/null +++ b/tests/test_app/jinja2_templates/host_port.html @@ -0,0 +1,20 @@ + + + + + + + + + ReactPy + + + +

ReactPy Test Page

+
+ Custom Host ({{ new_host }}): + {{ component("test_app.components.custom_host", host=new_host, number=0) }} +
+ + + diff --git a/tests/test_app/jinja2_templates/host_port_roundrobin.html b/tests/test_app/jinja2_templates/host_port_roundrobin.html new file mode 100644 index 00000000..47f5229a --- /dev/null +++ b/tests/test_app/jinja2_templates/host_port_roundrobin.html @@ -0,0 +1,22 @@ + + + + + + + + + ReactPy + + + +

ReactPy Test Page

+
+ {% for count_val in count %} + Round-Robin Host: + {{ component("test_app.components.custom_host", number=count_val) }} +
+ {% endfor %} + + + diff --git a/tests/test_app/jinja2_templates/offline.html b/tests/test_app/jinja2_templates/offline.html new file mode 100644 index 00000000..545895f8 --- /dev/null +++ b/tests/test_app/jinja2_templates/offline.html @@ -0,0 +1,19 @@ + + + + + + + + + ReactPy + + + +

ReactPy Offline Test Page

+
+ {{ component("test_app.offline.components.online", offline="test_app.offline.components.offline") }} +
+ + + diff --git a/tests/test_app/jinja2_templates/prerender.html b/tests/test_app/jinja2_templates/prerender.html new file mode 100644 index 00000000..cfea84b3 --- /dev/null +++ b/tests/test_app/jinja2_templates/prerender.html @@ -0,0 +1,33 @@ + + + + + + + + + ReactPy + + + +

ReactPy Prerender Test Page

+
+
+ {{ component("test_app.prerender.components.prerender_string", class="prerender-string", prerender="true") }} +
+
+
+ {{ component("test_app.prerender.components.prerender_vdom", class="prerender-vdom", prerender="true") }} +
+
+
+ {{ component("test_app.prerender.components.prerender_component", class="prerender-component", prerender="true") }} +
+
+ {{ component("test_app.prerender.components.use_user", prerender="true") }} +
+ {{ component("test_app.prerender.components.use_root_id", prerender="true") }} +
+ + + diff --git a/tests/test_app/jinja_env.py b/tests/test_app/jinja_env.py new file mode 100644 index 00000000..c162ec1d --- /dev/null +++ b/tests/test_app/jinja_env.py @@ -0,0 +1,12 @@ +"""Jinja2 environment configuration for the test app.""" + +from jinja2 import Environment + + +def environment(**options): + """Create a Jinja2 environment with the ReactPy extension loaded.""" + from reactpy_django.templatetags.jinja import ReactPyExtension + + env = Environment(**options) + env.add_extension(ReactPyExtension) + return env diff --git a/tests/test_app/jinja_urls.py b/tests/test_app/jinja_urls.py new file mode 100644 index 00000000..009b0d71 --- /dev/null +++ b/tests/test_app/jinja_urls.py @@ -0,0 +1,10 @@ +"""URL patterns for Jinja2 template views.""" + +from django.urls import path + +from . import jinja_views + +urlpatterns = [ + path("", jinja_views.jinja_base_template, name="jinja_base_template"), + path("errors/", jinja_views.jinja_errors_template, name="jinja_errors_template"), +] diff --git a/tests/test_app/jinja_views.py b/tests/test_app/jinja_views.py new file mode 100644 index 00000000..e21520ca --- /dev/null +++ b/tests/test_app/jinja_views.py @@ -0,0 +1,15 @@ +"""Jinja2 template views for the test app.""" + +from django.shortcuts import render + +from .types import TestObject + + +def jinja_base_template(request): + """Render the Jinja2 version of the base template.""" + return render(request, "base.html", {"my_object": TestObject(1)}, using="jinja2") + + +def jinja_errors_template(request): + """Render the Jinja2 version of the errors template.""" + return render(request, "errors.html", {}, using="jinja2") diff --git a/tests/test_app/settings_jinja.py b/tests/test_app/settings_jinja.py new file mode 100644 index 00000000..e83b0f79 --- /dev/null +++ b/tests/test_app/settings_jinja.py @@ -0,0 +1,146 @@ +import os +import sys +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent +SRC_DIR = BASE_DIR.parent / "src" + +# Quick-start development settings - unsuitable for production + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-n!bd1#+7ufw5#9ipayu9k(lyu@za$c2ajbro7es(v8_7w1$=&c" + +# Run in production mode when using a real web server +DEBUG = not any(sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn", "daphne"]) +ALLOWED_HOSTS = ["*"] + +# Application definition +INSTALLED_APPS = [ + "servestatic", + "daphne", # Overrides `runserver` command with an ASGI server + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "reactpy_django", # Django compatiblity layer for ReactPy + "test_app", # This test application + "django_bootstrap5", +] +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "servestatic.middleware.ServeStaticMiddleware", + "test_app.middleware.AutoCreateAdminMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] +ROOT_URLCONF = "test_app.urls" +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "test_app", "templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, + { + "BACKEND": "django.template.backends.jinja2.Jinja2", + "DIRS": [os.path.join(BASE_DIR, "test_app", "jinja2_templates")], + "OPTIONS": { + "environment": "test_app.jinja_env.environment", + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] +ASGI_APPLICATION = "test_app.asgi.application" +sys.path.append(str(SRC_DIR)) + +# Database +# WARNING: There are overrides in `test_components.py` that require no in-memory +# databases are used for testing. Make sure all SQLite databases are on disk. +DB_NAME = "single_db" +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"), + "TEST": { + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"), + "OPTIONS": {"timeout": 20}, + "DEPENDENCIES": [], + }, + "OPTIONS": {"timeout": 20}, + }, +} + +# Cache +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": os.path.join(BASE_DIR, "cache"), + } +} + +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +# Internationalization +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +# Default primary key field type +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +STATIC_ROOT = os.path.join(BASE_DIR, "static-deploy") + +# Static Files (CSS, JavaScript, Images) +STATIC_URL = "/static/" +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, "test_app", "static"), +] +STATICFILES_FINDERS = [ + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", +] + +# Logging +LOG_LEVEL = "DEBUG" +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": {"class": "logging.StreamHandler", "level": LOG_LEVEL}, + }, +} + +# Django Channels Settings +CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} + +# ReactPy-Django Settings +REACTPY_BACKHAUL_THREAD = any(sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn"]) + +# ServeStatic Settings +SERVESTATIC_USE_FINDERS = True +SERVESTATIC_AUTOREFRESH = True diff --git a/tests/test_app/tests/test_jinja.py b/tests/test_app/tests/test_jinja.py new file mode 100644 index 00000000..5945a9f6 --- /dev/null +++ b/tests/test_app/tests/test_jinja.py @@ -0,0 +1,105 @@ +"""Tests for Jinja2 template rendering with ReactPy components.""" + +from django.http import HttpRequest +from django.test import TestCase, override_settings + +# Jinja2 template backend configuration to add alongside the default Django templates +JINJA2_TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, + { + "BACKEND": "django.template.backends.jinja2.Jinja2", + "DIRS": [], + "OPTIONS": { + "environment": "test_app.jinja_env.environment", + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + + +class Jinja2ComponentTests(TestCase): + """Verify that ReactPy components render correctly in Jinja2 templates.""" + + @override_settings(TEMPLATES=JINJA2_TEMPLATES) + def test_component_function_available(self): + """The component function should be available in Jinja2 templates.""" + from jinja2 import Environment + + from reactpy_django.templatetags.jinja import ReactPyExtension + + env = Environment() + env.add_extension(ReactPyExtension) + + assert "component" in env.globals + assert callable(env.globals["component"]) + + @override_settings(TEMPLATES=JINJA2_TEMPLATES) + def test_pyscript_component_function_available(self): + """The pyscript_component function should be available in Jinja2 templates.""" + from jinja2 import Environment + + from reactpy_django.templatetags.jinja import ReactPyExtension + + env = Environment() + env.add_extension(ReactPyExtension) + + assert "pyscript_component" in env.globals + assert callable(env.globals["pyscript_component"]) + + @override_settings(TEMPLATES=JINJA2_TEMPLATES) + def test_pyscript_setup_function_available(self): + """The pyscript_setup function should be available in Jinja2 templates.""" + from jinja2 import Environment + + from reactpy_django.templatetags.jinja import ReactPyExtension + + env = Environment() + env.add_extension(ReactPyExtension) + + assert "pyscript_setup" in env.globals + assert callable(env.globals["pyscript_setup"]) + + @override_settings(TEMPLATES=JINJA2_TEMPLATES) + def test_jinja_component_renders_without_error(self): + """A Jinja2 template containing a component tag should render without error.""" + from django.template import engines + + request = HttpRequest() + request.method = "GET" + + jinja2_engine = engines["jinja2"] + template = jinja2_engine.from_string("{{ component('test_app.components.hello_world') }}") + rendered = template.render({"request": request}) + assert isinstance(rendered, str) + assert "mountComponent" in rendered + assert "test_app.components.hello_world" in rendered + + @override_settings(TEMPLATES=JINJA2_TEMPLATES) + def test_jinja_extension_configuration(self): + """The ReactPyExtension should be configurable via the environment function.""" + from test_app.jinja_env import environment + + env = environment() + assert "component" in env.globals + assert callable(env.globals["component"]) + assert "pyscript_component" in env.globals + assert callable(env.globals["pyscript_component"]) + assert "pyscript_setup" in env.globals + assert callable(env.globals["pyscript_setup"]) diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index 65672cdc..99be197b 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -36,5 +36,6 @@ path("", include("test_app.channel_layers.urls")), path("", include("test_app.forms.urls")), path("reactpy/", include("reactpy_django.http.urls")), + path("jinja/", include("test_app.jinja_urls")), path("admin/", admin.site.urls), ]