Skip to content

Commit

Permalink
Merge pull request #3 from tbrlpld/extract-wagtail-components
Browse files Browse the repository at this point in the history
Extract wagtail components into separate package
  • Loading branch information
tbrlpld authored Nov 29, 2023
2 parents 6cd0a6a + be341df commit 1c941a6
Show file tree
Hide file tree
Showing 17 changed files with 539 additions and 19 deletions.
258 changes: 241 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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 #}
<h1>Welcome to my app!</h1>
```

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("<h1>{}</h1>", "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 #}
<h1>Welcome to my app, {{ username }}!</h1>
```

### 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 %}
<head>
{{ media.js }}
{{ media.css }}
<head>
<body>
{% for panel in panels %}
{% component panel %}
{% endfor %}
</body>
```

## Contributing

Expand All @@ -31,24 +240,24 @@ 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:

#### 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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion laces/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
class LacesAppConfig(AppConfig):
label = "laces"
name = "laces"
verbose_name = "Wagtail laces"
verbose_name = "Laces"
57 changes: 57 additions & 0 deletions laces/components.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added laces/templatetags/__init__.py
Empty file.
Loading

0 comments on commit 1c941a6

Please sign in to comment.