Django components that know how to render themselves.
Laces components provide a simple way to combine data (in the form of Python objects) with the Django templates that are meant to render that data.
The components can then be simply rendered in any other template using the {% component %}
template tag.
That parent template does not need to know anything about the component's template or data.
No need to receive, filter, restructure or pass any data to the component's template.
Just let the component render itself.
Template and data are tied together (sorry, not sorry 😅) in the component, and they can be passed around together. This becomes especially useful when components are nested — it allows us to avoid building the same nested structure twice (once in the data and again in the templates).
Working with objects that know how to render themselves as HTML elements is a common pattern found in complex Django applications, such as the Wagtail admin interface. The Wagtail admin is also where the APIs provided in this package have previously been discovered, developed and solidified. The purpose of this package is to make these tools available to other Django projects outside the Wagtail ecosystem.
- Getting started
- Patterns for using components
- About Laces
- Contributing
- Changelog
- Discussions
- Security
First, install with pip:
$ python -m pip install laces
Then, add to your installed apps:
# settings.py
INSTALLED_APPS = ["laces", ...]
That's it.
The simplest way to create a component is to define a subclass of laces.components.Component
and specify a template_name
attribute on it.
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
{# my_app/templates/my_app/components/welcome.html #}
<h1>Hello World!</h1>
With the above in place, you then instantiate the component (e.g., in a view) and pass it to another template for rendering.
# my_app/views.py
from django.shortcuts import render
from my_app.components import WelcomePanel
def home(request):
welcome = WelcomePanel() # <-- Instantiates the component
return render(
request,
"my_app/home.html",
{"welcome": welcome}, # <-- Passes the component to the view template
)
In the view template, we load
the laces
tag library and use the {% component %}
tag to render the component.
{# my_app/templates/my_app/home.html #}
{% load laces %}
{% component welcome %} {# <-- Renders the component #}
That's it! The component's template will be rendered right there in the view template.
Of course, this is a very simple example and not much more useful than using a simple include
.
We will go into some more useful use cases below.
Before we dig deeper into the component use cases, just a quick note that components don't have to have a template.
For simple cases that don't require a template, the render_html
method can be overridden instead.
If the return value contains HTML, it should be marked as safe using django.utils.html.format_html
or django.utils.safestring.mark_safe
.
# 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=None):
return format_html("<h1>Hello World!</h1>")
Now back to components with templates.
The example shown above with the static welcome message in the template is, of course, not very useful.
It seems more like an overcomplicated way to replace a simple include
.
But, we rarely ever want to render templates with static content. Usually, we want to pass some context variables to the template to be rendered. This is where components start to become interesting.
The default implementation of render_html
calls the component's get_context_data
method to get the context variables to pass to the template.
The default implementation of get_context_data
returns an empty dictionary.
To customize the context variables passed to the template, we can override get_context_data
.
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
def get_context_data(self, parent_context=None):
return {"name": "Alice"}
{# my_app/templates/my_app/components/welcome.html #}
<h1>Hello {{ name }}</h1>
With the above we are now rendering a welcome message with the name coming from the component's get_context_data
method.
Nice.
But, still not very useful, as the name is still hardcoded — in the component method instead of the template, but hardcoded nonetheless.
When considering how to make the context of our components more useful, it's helpful to remember that components are just normal Python classes and objects. So, you are basically free to get the context data into the component in any way you like.
For example, we can pass arguments to the constructor and use them in the component's methods, like get_context_data
.
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
def __init__(self, name):
self.name = name
def get_context_data(self, parent_context=None):
return {"name": self.name}
Nice, this is getting better. Now we can pass the name to the component when we instantiate it and pass the component ready to be rendered to the view template.
# my_app/views.py
from django.shortcuts import render
from my_app.components import WelcomePanel
def home(request):
welcome = WelcomePanel(name="Alice")
return render(
request,
"my_app/home.html",
{"welcome": welcome},
)
So, as mentioned before, we can use the full power of Python classes and objects to provide context data to our components. A couple more examples of how components can be used can be found below.
You may have noticed in the above examples that the render_html
and get_context_data
methods take a parent_context
argument.
This is the context of the template that is rendering the component.
The parent_context
is passed into the render_html
method by the {% component %}
template tag.
The render_html
method, then, passes the parent_context
to the get_context_data
method.
The default implementation of the get_context_data
method, however, ignores the parent_context
argument and returns an empty dictionary.
To make use of it, you can override the get_context_data
method.
Relying on data from the parent context somewhat forgoes some of the benefits of components, which is tying the data and template together. Especially for nested uses of components, you now require that the data in the right format is passed through all layers of templates again. It is usually cleaner to provide all the data needed by the component directly to the component itself.
However, there may be cases where this is not possible or desirable.
For those cases, you have access to the parent context in the component's get_context_data
method.
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
def get_context_data(self, parent_context=None):
if not parent_context or "request" not in parent_context:
return {}
return {"name": parent_context["request"].user.first_name}
(Of course, this could have also been achieved by passing the request or user object to the component in the view, but this is just an example.)
As mentioned in the first example, components are rendered in other templates using the {% component %}
tag from the laces
tag library.
Here is that example from above again, in which the view passes an instance of WelcomePanel
to the context of my_app/home.html
.
# my_app/views.py
from django.shortcuts import render
from my_app.components import WelcomePanel
def home(request):
welcome = WelcomePanel()
return render(
request,
"my_app/home.html",
{
"welcome": welcome,
},
)
Then, in the my_app/templates/my_app/home.html
template we render the welcome panel component as follows:
{# my_app/templates/my_app/home.html #}
{% load laces %}
{% component welcome %}
This is the basic usage of components and should cover most cases.
However, the {% component %}
tag also supports some additional features.
Specifically, the keywords with
, only
and as
are supported, similar to how they work with the {% include %}
tag.
You can pass additional parent context variables to the component using the with
keyword.
{% component welcome with name=request.user.first_name %}
Note: These extra variables will be added to the parent_context
which is passed to the component's render_html
and get_context_data
methods.
The default implementation of get_context_data
ignores the parent_context
argument, so you will have to override it to make use of the extra variables.
For more information see the above section on the parent context.
To limit the parent context variables passed to the component to only those variables provided by the with
keyword (and no others from the calling template's context), use only
.
{% component welcome with name=request.user.first_name only %}
Note: Both, with
and only
, affect the parent_context
which is passed to the component's render_html
and get_context_data
methods. They do not have any direct effect on actual context that is passed to the component's template. E.g. if the component's get_context_data
method returns a dictionary which always contains a key foo
, then that key will be available in the component's template, regardless of whether only
was used or not.
To store the component's rendered output in a variable rather than outputting it immediately, use as
followed by the variable name.
{% component welcome as welcome_html %}
{{ welcome_html }}
Like Django form widgets, components can specify associated JavaScript and CSS assets.
The assets for a component can be specified in the same way that Django form assets are defined.
This can be achieved using either an inner Media
class or a dynamic media
property.
An inner Media
class definition looks like this:
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
class Media:
css = {"all": ("my_app/css/welcome-panel.css",)}
The more dynamic definition via a media
property looks like this:
# my_app/components.py
from django.forms import Media
from laces.components import Component
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
@property
def media(self):
return Media(css={"all": ("my_app/css/welcome-panel.css",)})
Note: It is your template's responsibility to output any media declarations defined on the components.
Once you have defined the assets on the component in one of the two ways above, you can output them in your templates.
This, again, works in the same way as it does for Django form widgets.
The component instance will have a media
property which returns an instance of the django.forms.Media
class.
This is the case, even if you used the nested Media
class to define the assets.
The string representation of Media
objects are the HTML declarations to include the assets.
In the example home template from above, we can output the component's media declarations like so:
{# my_app/templates/my_app/home.html #}
{% load laces %}
<head>
{{ welcome.media }}
<head>
<body>
{% component welcome %}
</body>
When you have many components in a page, it can be cumbersome to output the media declarations for each component individually.
To make that process a bit easier, Laces provides a MediaContainer
class.
The MediaContainer
class is a subclass of Python's built-in list
class which combines the media
of all it's members.
In a view we can create a MediaContainer
instance containing several media-defining components and pass it to the view template.
# my_app/views.py
from django.shortcuts import render
from laces.components import MediaContainer
from my_app.components import (
Dashboard,
Footer,
Header,
Sidebar,
WelcomePanel,
)
def home(request):
components = MediaContainer(
[
Header(),
Sidebar(),
WelcomePanel(),
Dashboard(),
Footer(),
]
)
return render(
request,
"my_app/home.html",
{
"components": components,
},
)
Then, in the view template, we can output the media declarations for all components in the container at once.
{# my_app/templates/my_app/home.html #}
{% load laces %}
<head>
{{ components.media }}
<head>
<body>
{% for component in components %}
{% component component %}
{% endfor %}
</body>
This will output a combined media declaration for all components in the container. The combination of the media declarations follows the behaviour outlined in the Django documentation.
Note:
The use of MediaContainer
is not limited to contain components.
It can be used to combine the media
properties of any kind of objects that have a media
property.
Below, we want to show a few more examples of how components can be used that were not covered in the "Getting started" section above.
The combination of data and template that components provide becomes especially useful when components are nested.
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component): ...
class Dashboard(Component):
template_name = "my_app/components/dashboard.html"
def __init__(self, user):
self.welcome = WelcomePanel(name=user.first_name)
...
def get_context_data(self, parent_context=None):
return {"welcome": self.welcome}
The template of the "parent" component does not need to know anything about the "child" component, except for which template variable is a component. The child component already contains the data it needs and knows which template to use to render that data.
{# my_app/templates/my_app/components/dashboard.html #}
{% load laces %}
<div class="dashboard">
{% component welcome %}
...
</div>
The nesting also provides us with a nice data structure we can test.
dashboard = Dashboard(user=request.user)
assert dashboard.welcome.name == request.user.first_name
The nesting of components is not limited to single instances. We can also nest groups of components.
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component): ...
class UsagePanel(Component): ...
class TeamPanel(Component): ...
class Dashboard(Component):
template_name = "my_app/components/dashboard.html"
def __init__(self, user):
self.panels = [
WelcomePanel(name=user.first_name),
UsagePanel(user=user),
TeamPanel(groups=user.groups.all()),
]
...
def get_context_data(self, parent_context=None):
return {"panels": self.panels}
{# my_app/templates/my_app/components/dashboard.html #}
{% load laces %}
<div class="dashboard">
{% for panel in panels %}
{% component panel %}
{% endfor %}
...
</div>
The above example is relatively static.
The Dashboard
component always contains the same panels.
You could also imagine passing the child components in through the constructor. This would make your component into a dynamic container component.
# my_app/components.py
from laces.components import Component
class Section(Component):
template_name = "my_app/components/section.html"
def __init__(self, children: list[Component]):
self.children = children
...
def get_context_data(self, parent_context=None):
return {"children": self.children}
class Heading(Component): ...
class Paragraph(Component): ...
class Image(Component): ...
{# my_app/templates/my_app/components/section.html #}
{% load laces %}
<section>
{% for child in children %}
{% component child %}
{% endfor %}
</section>
The above Section
component can take any kind of component as children.
The only thing that Section
requires is that the children can be rendered with the {% component %}
tag (which all components do).
In the view, we can now instantiate the Section
component with any children we want.
# my_app/views.py
from django.shortcuts import render
from my_app.components import (
Heading,
Image,
Paragraph,
Section,
)
def home(request):
content = Section(
children=[
Heading(...),
Paragraph(...),
Image(...),
]
)
return render(
request,
"my_app/home.html",
{"content": content},
)
{# my_app/templates/my_app/home.html #}
{% load laces %}
<body>
{% component content %}
...
</body>
Above, we showed how to use class properties to add data to the component's context. This is a very useful and common pattern. However, it is a bit verbose, especially when you have many properties and directly pass the properties to the template context.
To make this a little more convenient, we can use dataclasses
.
# my_app/components.py
from dataclasses import dataclass, asdict
from laces.components import Component
@dataclass
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
name: str
def get_context_data(self, parent_context=None):
return asdict(self)
With dataclasses we define the name and type of the properties we want to pass to the component in the class definition.
Then, we can use the asdict
function from the dataclasses
module to convert the dataclass instance to a dictionary that can be directly used as the template context.
The asdict
function only adds keys to the dictionary that were defined as the properties defined in the dataclass.
In the above example, the dictionary returned by asdict
would only contain the name
key.
It would not contain the template_name
key, because that is set on the class with a value but without a type annotation.
If you were to add the type annotation, then the template_name
key would also be included in the dictionary returned by asdict
.
When a component has many properties, it can be a pain to pass each property to the constructor individually. This is especially true when the component is used in many places and the data preparation would need to be repeated in each use case. Custom constructor methods can help with that.
In case of our WelcomePanel
example, we might want to show some more user information, including a profile image and link to the user's profile page.
We can add a classmethod
that takes the user object and returns an instance of the component with all the data needed to render the component.
We can also use this method to encapsulate the logic for generating additional data, such as the profile URL.
# my_app/components.py
from django import urls
from dataclasses import dataclass, asdict
from laces.components import Component
@dataclass
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
first_name: str
last_name: str
profile_url: str
profile_image_url: str
@classmethod
def from_user(cls, user):
profile_url = urls.reverse("profile", kwargs={"pk": user.pk})
return cls(
first_name=user.first_name,
last_name=user.last_name,
profile_url=profile_url,
profile_image_url=user.profile.image.url,
)
def get_context_data(self, parent_context=None):
return asdict(self)
Now, we can instantiate the component in the view like so:
# my_app/views.py
from django.shortcuts import render
from my_app.components import WelcomePanel
def home(request):
welcome = WelcomePanel.from_user(request.user)
return render(
request,
"my_app/home.html",
{"welcome": welcome},
)
The constructor method allows us to keep our view very simple and clean, as all the data preparation is encapsulated in the component.
As in the example above, custom constructor methods pair very well with the use of dataclasses, but they can also be used without them.
As mentioned in the introduction, the Laces package was extracted from Wagtail to make it available to the wider Django ecosystem.
The Wagtail documentation still contains the section on what was called originally called "Template Components".
While the code for these components was defined in submodules of the wagtail.admin
module, there was no limitation on them being only used in the Wagtail admin.
As of Wagtail release 6.0, Wagtail includes Laces as a dependency.
The original implementations of Component
and MediaContainer
classes as well as the {% component %}
template tag have been replaced by imports of the equivalents from Laces.
The names are still available at their original import locations, wagtail.admin.ui.components
and wagtail.admin.templatetags.wagtailadmin_tags
respectively.
So, if you have been using these imports before, no change is needed, they still work.
If you want to start using components in a Wagtail project, you can use the Wagtail or Laces import paths interchangeably.
To be able to use the Laces template tag library with {% load laces %}
, you need to add laces
you your INSTALLED_APPS
.
If you want to start using components with Wagtail on a release before 6.0, it is probably best to stick with the Wagtail imports. This guarantees you won't run into any conflicts when upgrading. All the patterns shown above should work regardless, only the import paths are different.
"Laces" is somewhat of a reference to the feature of tying data and templates together. The components are also "self-rendering," which could be seen as "self-reliance," which relates to "bootstrapping." And while "bootstraps" aren't really "(shoe) laces," my mind made the jump anyhow.
Finally, it's a nod to @mixxorz's fantastic Slippers package, which also takes a component-driven approach to improving the development experience with Django templates, but in a quite different way.
- Python >= 3.8
- Django >= 3.2
To make changes to this project, first clone this repository:
$ git clone https://github.com/tbrlpld/laces.git
$ cd laces
With your preferred virtualenv activated, install the development dependencies:
$ python -m pip install --upgrade pip>=21.3
$ python -m pip install -e '.[dev]' -U
$ python -m pip install flit
$ flit install
Note that this project uses pre-commit. It is included in the project testing requirements. To set up locally:
# initialize pre-commit
$ pre-commit install
# Optional, run all checks once for this, then the checks will run only on the changed files
$ git ls-files --others --cached --exclude-standard | xargs pre-commit run --files
Now you can run all tests like so:
$ tox
Or, you can run them for a specific environment:
$ tox -e python3.11-django4.2
Or, run only a specific test:
$ tox -e python3.11-django4.2 laces.tests.test_file.TestClass.test_method
To run the test app interactively, use:
$ tox -e interactive
You can now visit http://localhost:8020/
.
tox
is configured to run tests with coverage.
The coverage report is combined for all environments.
This is done by using the --append
flag when running coverage in tox
.
This means it will also include previous results.
You can see the coverage report by running:
$ coverage report
To get a clean report, you can run coverage erase
before running tox
.
If you want to run tests without tox
, you can use the testmanage.py
script.
This script is a wrapper around Django's manage.py
and will run tests with the correct settings.
To make this work, you need to have the testing
dependencies installed.
$ python -m pip install -e '.[testing]' -U
Then you can run tests with:
$ ./testmanage.py test
To run tests with coverage, use:
$ coverage run ./testmanage.py test
Tox will attempt to find installed Python versions on your machine.
If you use pyenv
to manage multiple versions, you can tell tox
to use those versions.
To ensure that tox
will find Python versions installed with pyenv
you need virtualenv-pyenv
(note: this is not pyenv-virtualenv
).
virtualenv-pyenv
is part of the development dependencies (just like tox
itself).
Additionally, you have to set the environment variable VIRTUALENV_DISCOVERY=pyenv
.
This project uses the Trusted Publisher model for PyPI releases. This means that publishing is done through GitHub Actions when a new release is created on GitHub.
Before publishing a new release, make sure to update
- the changelog in
CHANGELOG.md
, and - the version number in
laces/__init__.py
.
To update these files, you will have to create a release-prep branch and PR.
Once that PR is merged into main
you are ready to create the release.
To manually test publishing the package, you can use flit
.
Be sure to configure the testpypi
repository in your ~/.pypirc
file according to the Flit documentation.
If your PyPI account is using 2FA, you'll need to create a PyPI API token and use that as your password and __token__
as the username.
When you're ready to test the publishing, run:
$ flit build
$ flit publish --repository testpypi
Once you are ready to actually release the new version, you need to first create a git tag.
The tag name should be the version number prefixed with a v
(e.g. v0.1.0
).
To create the tag on the command line:
$ git switch main
$ git pull
$ git tag v0.1.1
$ git push --tags
Once the tag is on GitHub, you can visit the Tags screen. There you click "create release" in the overflow menu of the tag that you have just created. On the release screen you can click "generate release notes", which will compile release notes based on the merged PRs since the last release. Edit the generated release notes to make them a bit more concise (e.g. remove small fix-up PRs or group related changes).
Once the release notes are ready, click "publish release". This will trigger the release workflow, which you can observe on the "Actions" tab. When the workflow completes, check the new release on PyPI.