Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract wagtail components into separate package #3

Merged
merged 14 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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