diff --git a/README.md b/README.md index e7cc9b1..890d16e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ # Laces -Django components that know how to render themselves. - [![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD--3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) [![PyPI version](https://badge.fury.io/py/laces.svg)](https://badge.fury.io/py/laces) [![laces CI](https://github.com/tbrlpld/laces/actions/workflows/test.yml/badge.svg)](https://github.com/tbrlpld/laces/actions/workflows/test.yml) +--- + +Django components that know how to render themselves. + + +Working with objects that know how to render themselves as HTML elements is a common pattern found in complex Django applications (e.g. the [Wagtail](https://github.com/wagtail/wagtail) admin interface). +This package provides tools enable and support working with such objects, also known as "components". + +The APIs provided in the package have previously been discovered, developed and solidified in the Wagtail project. +The purpose of this package is to make these tools available to other Django projects outside the Wagtail ecosystem. + + ## Links - [Documentation](https://github.com/tbrlpld/laces/blob/main/README.md) @@ -16,13 +26,212 @@ Django components that know how to render themselves. ## Supported versions -- Python ... -- Django ... +- Python >= 3.8 +- Django >= 3.2 ## Installation -- `python -m pip install laces` -- ... +First, install with pip: +```sh +$ python -m pip install laces +``` + +Then, add to your installed apps: + +```python +# settings.py + +INSTALLED_APPS = ["laces", ...] +``` + +That's it. + +## Usage + +### Creating components + +The preferred way to create a component is to define a subclass of `laces.components.Component` and specify a `template_name` attribute on it. +The rendered template will then be used as the component's HTML representation: + +```python +# my_app/components.py + +from laces.components import Component + + +class WelcomePanel(Component): + template_name = "my_app/panels/welcome.html" + + +my_welcome_panel = WelcomePanel() +``` + +```html+django +{# my_app/templates/my_app/panels/welcome.html #} + +

Welcome to my app!

+``` + +For simple cases that don't require a template, the `render_html` method can be overridden instead: + +```python +# my_app/components.py + +from django.utils.html import format_html +from laces.components import Component + + +class WelcomePanel(Component): + def render_html(self, parent_context): + return format_html("

{}

", "Welcome to my app!") +``` + +### Passing context to the template + +The `get_context_data` method can be overridden to pass context variables to the template. +As with `render_html`, this receives the context dictionary from the calling template. + +```python +# my_app/components.py + +from laces.components import Component + + +class WelcomePanel(Component): + template_name = "my_app/panels/welcome.html" + + def get_context_data(self, parent_context): + context = super().get_context_data(parent_context) + context["username"] = parent_context["request"].user.username + return context +``` + +```html+django +{# my_app/templates/my_app/panels/welcome.html #} + +

Welcome to my app, {{ username }}!

+``` + +### Adding media definitions + +Like Django form widgets, components can specify associated JavaScript and CSS resources using either an inner `Media` class or a dynamic `media` property. + +```python +# my_app/components.py + +from laces.components import Component + + +class WelcomePanel(Component): + template_name = "my_app/panels/welcome.html" + + class Media: + css = {"all": ("my_app/css/welcome-panel.css",)} +``` + +### Using components in other templates + +The `laces` tag library provides a `{% component %}` tag for including components on a template. +This takes care of passing context variables from the calling template to the component (which would not be the case for a basic `{{ ... }}` variable tag). + +For example, given the view passes an instance of `WelcomePanel` to the context of `my_app/welcome.html`. + +```python +# my_app/views.py + +from django.shortcuts import render + +from my_app.components import WelcomePanel + + +def welcome_page(request): + panel = (WelcomePanel(),) + + return render( + request, + "my_app/welcome.html", + { + "panel": panel, + }, + ) +``` + +The template `my_app/templates/my_app/welcome.html` could render the panel as follows: + +```html+django +{# my_app/templates/my_app/welcome.html #} + +{% load laces %} +{% component panel %} +``` + +You can pass additional context variables to the component using the keyword `with`: + +```html+django +{% component panel with username=request.user.username %} +``` + +To render the component with only the variables provided (and no others from the calling template's context), use `only`: + +```html+django +{% component panel with username=request.user.username only %} +``` + +To store the component's rendered output in a variable rather than outputting it immediately, use `as` followed by the variable name: + +```html+django +{% component panel as panel_html %} + +{{ panel_html }} +``` + +Note that it is your template's responsibility to output any media declarations defined on the components. +This can be done by constructing a media object for the whole page within the view, passing this to the template, and outputting it via `media.js` and `media.css`. + +```python +# my_app/views.py + +from django.forms import Media +from django.shortcuts import render + +from my_app.components import WelcomePanel + + +def welcome_page(request): + panels = [ + WelcomePanel(), + ] + + media = Media() + for panel in panels: + media += panel.media + + render( + request, + "my_app/welcome.html", + { + "panels": panels, + "media": media, + }, + ) +``` + + +```html+django +{# my_app/templates/my_app/welcome.html #} + +{% load laces %} + + + {{ media.js }} + {{ media.css }} + + + {% for panel in panels %} + {% component panel %} + {% endfor %} + +``` ## Contributing @@ -31,8 +240,8 @@ Django components that know how to render themselves. To make changes to this project, first clone this repository: ```sh -git clone https://github.com/tbrlpld/laces.git -cd laces +$ git clone https://github.com/tbrlpld/laces.git +$ cd laces ``` With your preferred virtualenv activated, install testing dependencies: @@ -40,15 +249,15 @@ With your preferred virtualenv activated, install testing dependencies: #### Using pip ```sh -python -m pip install --upgrade pip>=21.3 -python -m pip install -e '.[testing]' -U +$ python -m pip install --upgrade pip>=21.3 +$ python -m pip install -e '.[testing]' -U ``` #### Using flit ```sh -python -m pip install flit -flit install +$ python -m pip install flit +$ flit install ``` ### pre-commit @@ -68,16 +277,31 @@ $ git ls-files --others --cached --exclude-standard | xargs pre-commit run --fil ### How to run tests -Now you can run tests as shown below: +Now you can run all tests like so: ```sh -tox +$ tox ``` -or, you can run them for a specific environment `tox -e python3.11-django4.2-wagtail5.1` or specific test -`tox -e python3.11-django4.2-wagtail5.1-sqlite laces.tests.test_file.TestClass.test_method` +Or, you can run them for a specific environment: + +```sh +$ tox -e python3.11-django4.2-wagtail5.1 +``` + +Or, run only a specific test: + +```sh +$ tox -e python3.11-django4.2-wagtail5.1-sqlite laces.tests.test_file.TestClass.test_method +``` + +To run the test app interactively, use: + +```sh +$ tox -e interactive +``` -To run the test app interactively, use `tox -e interactive`, visit `http://127.0.0.1:8020/admin/` and log in with `admin`/`changeme`. +You can now visit `http://localhost:8020/`. ### Python version management diff --git a/laces/apps.py b/laces/apps.py index e7a190b..bc37fbd 100644 --- a/laces/apps.py +++ b/laces/apps.py @@ -4,4 +4,4 @@ class LacesAppConfig(AppConfig): label = "laces" name = "laces" - verbose_name = "Wagtail laces" + verbose_name = "Laces" diff --git a/laces/components.py b/laces/components.py new file mode 100644 index 0000000..46fbadf --- /dev/null +++ b/laces/components.py @@ -0,0 +1,57 @@ +from typing import Any, MutableMapping + +from django.forms import Media, MediaDefiningClass +from django.template import Context +from django.template.loader import get_template + + +class Component(metaclass=MediaDefiningClass): + """ + A class that knows how to render itself. + + Extracted from Wagtail. See: + https://github.com/wagtail/wagtail/blob/094834909d5c4b48517fd2703eb1f6d386572ffa/wagtail/admin/ui/components.py#L8-L22 # noqa: E501 + """ + + def get_context_data( + self, parent_context: MutableMapping[str, Any] + ) -> MutableMapping[str, Any]: + return {} + + def render_html(self, parent_context: MutableMapping[str, Any] = None) -> str: + """ + Return string representation of the object. + + Given a context dictionary from the calling template (which may be a + `django.template.Context` object or a plain ``dict`` of context variables), + returns the string representation to be rendered. + + This will be subject to Django's HTML escaping rules, so a return value + consisting of HTML should typically be returned as a + `django.utils.safestring.SafeString` instance. + """ + if parent_context is None: + parent_context = Context() + context_data = self.get_context_data(parent_context) + if context_data is None: + raise TypeError("Expected a dict from get_context_data, got None") + + template = get_template(self.template_name) + return template.render(context_data) + + +class MediaContainer(list): + """ + A list that provides a ``media`` property that combines the media definitions + of its members. + + Extracted from Wagtail. See: + https://github.com/wagtail/wagtail/blob/ca8a87077b82e20397e5a5b80154d923995e6ca9/wagtail/admin/ui/components.py#L25-L36 # noqa: E501 + """ + + @property + def media(self): + media = Media() + for item in self: + media += item.media + return media diff --git a/laces/templatetags/__init__.py b/laces/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laces/templatetags/laces.py b/laces/templatetags/laces.py new file mode 100644 index 0000000..761960f --- /dev/null +++ b/laces/templatetags/laces.py @@ -0,0 +1,122 @@ +from django import template +from django.template.base import token_kwargs +from django.template.defaultfilters import conditional_escape + + +register = template.library.Library() + + +class ComponentNode(template.Node): + """ + Template node to render a component. + + Extracted from Wagtail. See: + https://github.com/wagtail/wagtail/blob/094834909d5c4b48517fd2703eb1f6d386572ffa/wagtail/admin/templatetags/wagtailadmin_tags.py#L937-L987 # noqa: E501 + """ + + def __init__( + self, + component, + extra_context=None, + isolated_context=False, + fallback_render_method=None, + target_var=None, + ): + self.component = component + self.extra_context = extra_context or {} + self.isolated_context = isolated_context + self.fallback_render_method = fallback_render_method + self.target_var = target_var + + def render(self, context: template.Context) -> str: + # Render a component by calling its render_html method, passing request and context from the + # calling template. + # If fallback_render_method is true, objects without a render_html method will have render() + # called instead (with no arguments) - this is to provide deprecation path for things that have + # been newly upgraded to use the component pattern. + + component = self.component.resolve(context) + + if self.fallback_render_method: + fallback_render_method = self.fallback_render_method.resolve(context) + else: + fallback_render_method = False + + values = { + name: var.resolve(context) for name, var in self.extra_context.items() + } + + if hasattr(component, "render_html"): + if self.isolated_context: + html = component.render_html(context.new(values)) + else: + with context.push(**values): + html = component.render_html(context) + elif fallback_render_method and hasattr(component, "render"): + html = component.render() + else: + raise ValueError(f"Cannot render {component!r} as a component") + + if self.target_var: + context[self.target_var] = html + return "" + else: + if context.autoescape: + html = conditional_escape(html) + return html + + +@register.tag(name="component") +def component(parser, token): + """ + Template tag to render a component via ComponentNode. + + Extracted from Wagtail. See: + https://github.com/wagtail/wagtail/blob/094834909d5c4b48517fd2703eb1f6d386572ffa/wagtail/admin/templatetags/wagtailadmin_tags.py#L990-L1037 # noqa: E501 + """ + bits = token.split_contents()[1:] + if not bits: + raise template.TemplateSyntaxError( + "'component' tag requires at least one argument, the component object" + ) + + component = parser.compile_filter(bits.pop(0)) + + # the only valid keyword argument immediately following the component + # is fallback_render_method + flags = token_kwargs(bits, parser) + fallback_render_method = flags.pop("fallback_render_method", None) + if flags: + raise template.TemplateSyntaxError( + "'component' tag only accepts 'fallback_render_method' as a keyword argument" + ) + + extra_context = {} + isolated_context = False + target_var = None + + while bits: + bit = bits.pop(0) + if bit == "with": + extra_context = token_kwargs(bits, parser) + elif bit == "only": + isolated_context = True + elif bit == "as": + try: + target_var = bits.pop(0) + except IndexError: + raise template.TemplateSyntaxError( + "'component' tag with 'as' must be followed by a variable name" + ) + else: + raise template.TemplateSyntaxError( + "'component' tag received an unknown argument: %r" % bit + ) + + return ComponentNode( + component, + extra_context=extra_context, + isolated_context=isolated_context, + fallback_render_method=fallback_render_method, + target_var=target_var, + ) diff --git a/laces/test/apps.py b/laces/test/apps.py index a346248..c06b941 100644 --- a/laces/test/apps.py +++ b/laces/test/apps.py @@ -4,4 +4,4 @@ class LacesTestAppConfig(AppConfig): label = "laces_test" name = "laces.test" - verbose_name = "Wagtail laces tests" + verbose_name = "Laces test app" diff --git a/laces/test/components/__init__.py b/laces/test/components/__init__.py new file mode 100644 index 0000000..45f4f4c --- /dev/null +++ b/laces/test/components/__init__.py @@ -0,0 +1 @@ +from .heading import * # noqa: F401, F403 diff --git a/laces/test/components/heading.py b/laces/test/components/heading.py new file mode 100644 index 0000000..23f2557 --- /dev/null +++ b/laces/test/components/heading.py @@ -0,0 +1,5 @@ +from laces.components import Component + + +class Heading(Component): + template_name = "components/heading.html" diff --git a/laces/test/components/templates/components/heading.html b/laces/test/components/templates/components/heading.html new file mode 100644 index 0000000..e583759 --- /dev/null +++ b/laces/test/components/templates/components/heading.html @@ -0,0 +1 @@ +

Test

diff --git a/laces/test/home/__init__.py b/laces/test/home/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laces/test/home/templates/home/home.html b/laces/test/home/templates/home/home.html new file mode 100644 index 0000000..1a00229 --- /dev/null +++ b/laces/test/home/templates/home/home.html @@ -0,0 +1,2 @@ +{% load laces %} +{% component heading %} diff --git a/laces/test/home/views.py b/laces/test/home/views.py new file mode 100644 index 0000000..ea1f6fe --- /dev/null +++ b/laces/test/home/views.py @@ -0,0 +1,12 @@ +from django.shortcuts import render + +from laces.test.components import Heading + + +def home(request): + heading = Heading() + return render( + request, + template_name="home/home.html", + context={"heading": heading}, + ) diff --git a/laces/test/settings.py b/laces/test/settings.py index 0e2af9d..b2148d7 100644 --- a/laces/test/settings.py +++ b/laces/test/settings.py @@ -35,6 +35,8 @@ INSTALLED_APPS = [ "laces", "laces.test", + "laces.test.home", + "laces.test.components", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", diff --git a/laces/test/urls.py b/laces/test/urls.py index f690a95..f586e93 100644 --- a/laces/test/urls.py +++ b/laces/test/urls.py @@ -1,7 +1,10 @@ from django.contrib import admin from django.urls import path +from laces.test.home.views import home + urlpatterns = [ + path("", home), path("django-admin/", admin.site.urls), ] diff --git a/laces/tests/__init__.py b/laces/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laces/tests/test_templatetags/__init__.py b/laces/tests/test_templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/laces/tests/test_templatetags/test_laces.py b/laces/tests/test_templatetags/test_laces.py new file mode 100644 index 0000000..cc29047 --- /dev/null +++ b/laces/tests/test_templatetags/test_laces.py @@ -0,0 +1,91 @@ +from django.template import Context, Template +from django.test import SimpleTestCase +from django.utils.html import format_html + +from laces.components import Component + + +class TestComponentTag(SimpleTestCase): + """ + Test for the `component` template tag. + + Extracted from Wagtail. See: + https://github.com/wagtail/wagtail/blob/main/wagtail/admin/tests/test_templatetags.py#L225-L305 # noqa: E501 + """ + + def test_passing_context_to_component(self): + class MyComponent(Component): + def render_html(self, parent_context): + return format_html( + "

{} was here

", parent_context.get("first_name", "nobody") + ) + + template = Template( + "{% load laces %}{% with first_name='Kilroy' %}{% component my_component %}{% endwith %}" + ) + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "

Kilroy was here

") + + template = Template( + "{% load laces %}{% component my_component with first_name='Kilroy' %}" + ) + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "

Kilroy was here

") + + template = Template( + "{% load laces %}{% with first_name='Kilroy' %}{% component my_component with surname='Silk' only %}{% endwith %}" + ) + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "

nobody was here

") + + def test_fallback_render_method(self): + class MyComponent(Component): + def render_html(self, parent_context): + return format_html("

I am a component

") + + class MyNonComponent: + def render(self): + return format_html("

I am not a component

") + + template = Template("{% load laces %}{% component my_component %}") + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "

I am a component

") + with self.assertRaises(ValueError): + template.render(Context({"my_component": MyNonComponent()})) + + template = Template( + "{% load laces %}{% component my_component fallback_render_method=True %}" + ) + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "

I am a component

") + html = template.render(Context({"my_component": MyNonComponent()})) + self.assertEqual(html, "

I am not a component

") + + def test_component_escapes_unsafe_strings(self): + class MyComponent(Component): + def render_html(self, parent_context): + return "Look, I'm running with scissors! 8< 8< 8<" + + template = Template("{% load laces %}

{% component my_component %}

") + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual( + html, "

Look, I'm running with scissors! 8< 8< 8<

" + ) + + def test_error_on_rendering_non_component(self): + template = Template("{% load laces %}

{% component my_component %}

") + + with self.assertRaises(ValueError) as cm: + template.render(Context({"my_component": "hello"})) + self.assertEqual(str(cm.exception), "Cannot render 'hello' as a component") + + def test_render_as_var(self): + class MyComponent(Component): + def render_html(self, parent_context): + return format_html("

I am a component

") + + template = Template( + "{% load laces %}{% component my_component as my_html %}The result was: {{ my_html }}" + ) + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "The result was:

I am a component

")