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
")