diff --git a/.coveragerc b/.coveragerc
index 4e1ef99..03e0b31 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -22,3 +22,4 @@ exclude_lines =
if __name__ == .__main__.:
ignore_errors = True
+show_missing = True
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4404f25..f343ce3 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -49,3 +49,9 @@ jobs:
run: tox
env:
DB: sqlite
+ - name: Upload coverage reports to Codecov
+ uses: codecov/codecov-action@v3
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+ with:
+ files: ./.coverage.json
diff --git a/.gitignore b/.gitignore
index 4060bfb..ba4a900 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ __pycache__/
/dist
/laces.egg-info
/.coverage
+/.coverage.json
/htmlcov
/.tox
/.venv
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cff896..ae4d1f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-- ...
+- Add more tests and example usage.
### Changed
diff --git a/README.md b/README.md
index 48c7ebf..db24ffa 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
[![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)
+[![codecov](https://codecov.io/gh/tbrlpld/laces/graph/badge.svg?token=FMHEHNVPSX)](https://codecov.io/gh/tbrlpld/laces)
---
@@ -50,8 +51,7 @@ That's it.
### 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:
+The simplest way to create a component is to define a subclass of `laces.components.Component` and specify a `template_name` attribute on it.
```python
# my_app/components.py
@@ -60,19 +60,54 @@ from laces.components import Component
class WelcomePanel(Component):
- template_name = "my_app/panels/welcome.html"
+ template_name = "my_app/components/welcome.html"
+```
+
+```html+django
+{# my_app/templates/my_app/components/welcome.html #}
+
Welcome to my app!
+```
+
+With the above in place, you then instantiate the component (e.g. in a view) and pass it to another template for rendering.
+
+```python
+# my_app/views.py
-my_welcome_panel = WelcomePanel()
+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.
+
```html+django
-{# my_app/templates/my_app/panels/welcome.html #}
+{# my_app/templates/my_app/home.html #}
-Welcome to my app!
+{% load laces %}
+{% component welcome %}
```
-For simple cases that don't require a template, the `render_html` method can be overridden instead:
+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.
+
+### Without a template
+
+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`.
```python
# my_app/components.py
@@ -82,11 +117,11 @@ from laces.components import Component
class WelcomePanel(Component):
- def render_html(self, parent_context):
- return format_html("{}
", "Welcome to my app!")
+ def render_html(self, parent_context=None):
+ return format_html("Welcome to my app!
")
```
-### Passing context to the template
+### Passing context to the component 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.
@@ -98,7 +133,7 @@ from laces.components import Component
class WelcomePanel(Component):
- template_name = "my_app/panels/welcome.html"
+ template_name = "my_app/components/welcome.html"
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
@@ -107,7 +142,7 @@ class WelcomePanel(Component):
```
```html+django
-{# my_app/templates/my_app/panels/welcome.html #}
+{# my_app/templates/my_app/components/welcome.html #}
Welcome to my app, {{ username }}!
```
@@ -123,7 +158,7 @@ from laces.components import Component
class WelcomePanel(Component):
- template_name = "my_app/panels/welcome.html"
+ template_name = "my_app/components/welcome.html"
class Media:
css = {"all": ("my_app/css/welcome-panel.css",)}
@@ -134,7 +169,7 @@ class WelcomePanel(Component):
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`.
+For example, given the view passes an instance of `WelcomePanel` to the context of `my_app/home.html`.
```python
# my_app/views.py
@@ -144,45 +179,45 @@ from django.shortcuts import render
from my_app.components import WelcomePanel
-def welcome_page(request):
- panel = (WelcomePanel(),)
+def home(request):
+ welcome = WelcomePanel()
return render(
request,
- "my_app/welcome.html",
+ "my_app/home.html",
{
- "panel": panel,
+ "welcome": welcome,
},
)
```
-The template `my_app/templates/my_app/welcome.html` could render the panel as follows:
+The template `my_app/templates/my_app/home.html` could render the welcome panel component as follows:
```html+django
-{# my_app/templates/my_app/welcome.html #}
+{# my_app/templates/my_app/home.html #}
{% load laces %}
-{% component panel %}
+{% component welcome %}
```
You can pass additional context variables to the component using the keyword `with`:
```html+django
-{% component panel with username=request.user.username %}
+{% component welcome 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 %}
+{% component welcome 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 %}
+{% component welcome as welcome_html %}
-{{ panel_html }}
+{{ welcome_html }}
```
Note that it is your template's responsibility to output any media declarations defined on the components.
@@ -197,20 +232,20 @@ from django.shortcuts import render
from my_app.components import WelcomePanel
-def welcome_page(request):
- panels = [
+def home(request):
+ components = [
WelcomePanel(),
]
media = Media()
- for panel in panels:
- media += panel.media
+ for component in components:
+ media += component.media
render(
request,
- "my_app/welcome.html",
+ "my_app/home.html",
{
- "panels": panels,
+ "components": components,
"media": media,
},
)
@@ -218,7 +253,7 @@ def welcome_page(request):
```html+django
-{# my_app/templates/my_app/welcome.html #}
+{# my_app/templates/my_app/home.html #}
{% load laces %}
@@ -227,8 +262,8 @@ def welcome_page(request):
{{ media.css }}
- {% for panel in panels %}
- {% component panel %}
+ {% for comp in components %}
+ {% component comp %}
{% endfor %}
```
@@ -303,6 +338,25 @@ $ tox -e interactive
You can now visit `http://localhost:8020/`.
+#### Testing with coverage
+
+To run tests with coverage, use:
+
+```sh
+$ coverage run ./testmanage.py test
+```
+
+Then see the results with
+
+```sh
+$ coverage report
+```
+
+When the tests are run with `tox`, 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.
+To get a clean report, you can run `coverage erase` before running `tox`.
+
### Python version management
Tox will attempt to find installed Python versions on your machine.
diff --git a/laces/components.py b/laces/components.py
index 46fbadf..ba8f545 100644
--- a/laces/components.py
+++ b/laces/components.py
@@ -11,19 +11,19 @@ class Component(metaclass=MediaDefiningClass):
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 {}
+ A component uses the `MetaDefiningClass` metaclass to add a `media` property, which
+ allows the definitions of CSS and JavaScript assets that are associated with the
+ component. This works the same as `Media` class used by Django forms.
+ See also: https://docs.djangoproject.com/en/4.2/topics/forms/media/
+ """
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),
+ `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
@@ -39,18 +39,39 @@ def render_html(self, parent_context: MutableMapping[str, Any] = None) -> str:
template = get_template(self.template_name)
return template.render(context_data)
+ def get_context_data(
+ self, parent_context: MutableMapping[str, Any]
+ ) -> MutableMapping[str, Any]:
+ return {}
+
class MediaContainer(list):
"""
- A list that provides a ``media`` property that combines the media definitions
+ 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
+
+ The `MediaContainer` functionality depends on the `django.forms.widgets.Media`
+ class. The `Media` class provides the logic to combine the media definitions of
+ multiple objects through its `__add__` method. The `MediaContainer` relies on this
+ functionality to provide a `media` property that combines the media definitions of
+ its members.
+
+ See also:
+ https://docs.djangoproject.com/en/4.2/topics/forms/media
"""
@property
def media(self):
+ """
+ Return a `Media` object containing the media definitions of all members.
+
+ This makes use of the `Media.__add__` method, which combines the media
+ definitions of two `Media` objects.
+
+ """
media = Media()
for item in self:
media += item.media
diff --git a/laces/models.py b/laces/templates/.gitkeep
similarity index 100%
rename from laces/models.py
rename to laces/templates/.gitkeep
diff --git a/laces/templatetags/laces.py b/laces/templatetags/laces.py
index 761960f..5c7c864 100644
--- a/laces/templatetags/laces.py
+++ b/laces/templatetags/laces.py
@@ -29,12 +29,28 @@ def __init__(
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.
+ """
+ Render the ComponentNode template node.
+ The rendering is done by rendering the passed component by calling its
+ `render_html` method and passing context from the calling template.
+
+ If the passed object does not have a `render_html` method but a `render` method
+ and the `fallback_render_method` arguments of the template tag is true, then
+ the `render` method is used. The `render` method does not receive any arguments.
+
+ Additional context variables can be passed to the component by using the `with`
+ keyword. The `with` keyword accepts a list of key-value pairs. The key is the
+ name of the context variable and the value is the value of the context variable.
+
+ The `only` keyword can be used to isolate the context. This means that the
+ context variables from the parent context are not passed to the component the
+ only context variables passed to the component are the ones passed with the
+ `with` keyword.
+
+ The `as` keyword can be used to store the rendered component in a variable
+ in the parent context. The variable name is passed after the `as` keyword.
+ """
component = self.component.resolve(context)
if self.fallback_render_method:
@@ -84,9 +100,9 @@ def component(parser, token):
# 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:
+ kwargs = token_kwargs(bits, parser)
+ fallback_render_method = kwargs.pop("fallback_render_method", None)
+ if kwargs:
raise template.TemplateSyntaxError(
"'component' tag only accepts 'fallback_render_method' as a keyword argument"
)
diff --git a/laces/test/components/__init__.py b/laces/test/components/__init__.py
deleted file mode 100644
index 45f4f4c..0000000
--- a/laces/test/components/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .heading import * # noqa: F401, F403
diff --git a/laces/test/components/heading.py b/laces/test/components/heading.py
deleted file mode 100644
index 23f2557..0000000
--- a/laces/test/components/heading.py
+++ /dev/null
@@ -1,5 +0,0 @@
-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
deleted file mode 100644
index e583759..0000000
--- a/laces/test/components/templates/components/heading.html
+++ /dev/null
@@ -1 +0,0 @@
-Test
diff --git a/laces/test/home/__init__.py b/laces/test/example/__init__.py
similarity index 100%
rename from laces/test/home/__init__.py
rename to laces/test/example/__init__.py
diff --git a/laces/test/example/components.py b/laces/test/example/components.py
new file mode 100644
index 0000000..43ea303
--- /dev/null
+++ b/laces/test/example/components.py
@@ -0,0 +1,123 @@
+"""
+Examples of how components might be defined.
+
+This is unlikely to be an exhaustive list of examples, but it should be enough to
+demonstrate the basic concepts of how components work.
+"""
+from dataclasses import asdict, dataclass
+
+from django.utils.html import format_html
+
+from laces.components import Component
+
+
+class RendersTemplateWithFixedContentComponent(Component):
+ template_name = "components/hello-world.html"
+
+
+class ReturnsFixedContentComponent(Component):
+ def render_html(self, parent_context=None):
+ return format_html("Hello World Return
\n")
+
+
+class PassesFixedNameToContextComponent(Component):
+ template_name = "components/hello-name.html"
+
+ def get_context_data(self, parent_context=None):
+ return {"name": "Alice"}
+
+
+class PassesInstanceAttributeToContextComponent(Component):
+ template_name = "components/hello-name.html"
+
+ def __init__(self, name, **kwargs):
+ super().__init__(**kwargs)
+ self.name = name
+
+ def get_context_data(self, parent_context=None):
+ return {"name": self.name}
+
+
+class PassesSelfToContextComponent(Component):
+ template_name = "components/hello-self-name.html"
+
+ def __init__(self, name, **kwargs):
+ super().__init__(**kwargs)
+ self.name = name
+
+ def get_context_data(self, parent_context=None):
+ return {"self": self}
+
+
+@dataclass
+class DataclassAsDictContextComponent(Component):
+ template_name = "components/hello-name.html"
+
+ name: str
+
+ def get_context_data(self, parent_context=None):
+ return asdict(self)
+
+
+class PassesNameFromParentContextComponent(Component):
+ template_name = "components/hello-name.html"
+
+ def get_context_data(self, parent_context):
+ return {"name": parent_context["name"]}
+
+
+class SectionWithHeadingAndParagraphComponent(Component):
+ template_name = "components/section.html"
+
+ def __init__(self, heading: "HeadingComponent", content: "ParagraphComponent"):
+ super().__init__()
+ self.heading = heading
+ self.content = content
+
+ def get_context_data(self, parent_context=None):
+ return {
+ "heading": self.heading,
+ "content": self.content,
+ }
+
+
+class HeadingComponent(Component):
+ def __init__(self, text: str):
+ super().__init__()
+ self.text = text
+
+ def render_html(self, parent_context=None):
+ return format_html("{}
\n", self.text)
+
+
+class ParagraphComponent(Component):
+ def __init__(self, text: str):
+ super().__init__()
+ self.text = text
+
+ def render_html(self, parent_context=None):
+ return format_html("{}
\n", self.text)
+
+
+class ListSectionComponent(Component):
+ template_name = "components/list-section.html"
+
+ def __init__(self, heading: "HeadingComponent", items: "list[Component]"):
+ super().__init__()
+ self.heading = heading
+ self.items = items
+
+ def get_context_data(self, parent_context=None):
+ return {
+ "heading": self.heading,
+ "items": self.items,
+ }
+
+
+class BlockquoteComponent(Component):
+ def __init__(self, text: str):
+ super().__init__()
+ self.text = text
+
+ def render_html(self, parent_context=None):
+ return format_html("{}
\n", self.text)
diff --git a/laces/test/example/templates/components/hello-name.html b/laces/test/example/templates/components/hello-name.html
new file mode 100644
index 0000000..71499e2
--- /dev/null
+++ b/laces/test/example/templates/components/hello-name.html
@@ -0,0 +1 @@
+Hello {{ name }}
diff --git a/laces/test/example/templates/components/hello-self-name.html b/laces/test/example/templates/components/hello-self-name.html
new file mode 100644
index 0000000..4bc30e3
--- /dev/null
+++ b/laces/test/example/templates/components/hello-self-name.html
@@ -0,0 +1 @@
+Hello {{ self.name }}'s self
diff --git a/laces/test/example/templates/components/hello-world.html b/laces/test/example/templates/components/hello-world.html
new file mode 100644
index 0000000..f3e333e
--- /dev/null
+++ b/laces/test/example/templates/components/hello-world.html
@@ -0,0 +1 @@
+Hello World
diff --git a/laces/test/example/templates/components/list-section.html b/laces/test/example/templates/components/list-section.html
new file mode 100644
index 0000000..e48382a
--- /dev/null
+++ b/laces/test/example/templates/components/list-section.html
@@ -0,0 +1,9 @@
+{% load laces %}
+
+ {% component heading %}
+
+ {% for item in items %}
+ - {% component item %}
+ {% endfor %}
+
+
diff --git a/laces/test/example/templates/components/section.html b/laces/test/example/templates/components/section.html
new file mode 100644
index 0000000..641fc96
--- /dev/null
+++ b/laces/test/example/templates/components/section.html
@@ -0,0 +1,5 @@
+{% load laces %}
+
+ {% component heading %}
+ {% component content %}
+
diff --git a/laces/test/example/templates/pages/kitchen-sink.html b/laces/test/example/templates/pages/kitchen-sink.html
new file mode 100644
index 0000000..a880c25
--- /dev/null
+++ b/laces/test/example/templates/pages/kitchen-sink.html
@@ -0,0 +1,24 @@
+{% load laces %}
+
+
+
+ Kitchen Sink
+
+
+ {% component fixed_content_template %}
+ {% component fixed_content_return %}
+ {% component passes_fixed_name %}
+ {% component passes_instance_attr_name %}
+ {% component passes_self %}
+ {% component dataclass_attr_name %}
+ {% component passes_name_from_parent_context %}
+ {% with name="Erin" %}
+ {% comment %}
+ Override parent context through the with-block. The same component receives a different parent context.
+ {% endcomment %}
+ {% component passes_name_from_parent_context %}
+ {% endwith %}
+ {% component section_with_heading_and_paragraph %}
+ {% component list_section %}
+
+
diff --git a/laces/test/example/views.py b/laces/test/example/views.py
new file mode 100644
index 0000000..d295c15
--- /dev/null
+++ b/laces/test/example/views.py
@@ -0,0 +1,56 @@
+from django.shortcuts import render
+
+from laces.test.example.components import (
+ BlockquoteComponent,
+ DataclassAsDictContextComponent,
+ HeadingComponent,
+ ListSectionComponent,
+ ParagraphComponent,
+ PassesFixedNameToContextComponent,
+ PassesInstanceAttributeToContextComponent,
+ PassesNameFromParentContextComponent,
+ PassesSelfToContextComponent,
+ RendersTemplateWithFixedContentComponent,
+ ReturnsFixedContentComponent,
+ SectionWithHeadingAndParagraphComponent,
+)
+
+
+def kitchen_sink(request):
+ """Render a page with all example components."""
+ fixed_content_template = RendersTemplateWithFixedContentComponent()
+ fixed_content_return = ReturnsFixedContentComponent()
+ passes_fixed_name = PassesFixedNameToContextComponent()
+ passes_instance_attr_name = PassesInstanceAttributeToContextComponent(name="Bob")
+ passes_self = PassesSelfToContextComponent(name="Carol")
+ dataclass_attr_name = DataclassAsDictContextComponent(name="Charlie")
+ passes_name_from_parent_context = PassesNameFromParentContextComponent()
+ section_with_heading_and_paragraph = SectionWithHeadingAndParagraphComponent(
+ heading=HeadingComponent(text="Hello"),
+ content=ParagraphComponent(text="World"),
+ )
+ list_section = ListSectionComponent(
+ heading=HeadingComponent(text="Heading"),
+ items=[
+ ParagraphComponent(text="Item 1"),
+ BlockquoteComponent(text="Item 2"),
+ ParagraphComponent(text="Item 3"),
+ ],
+ )
+
+ return render(
+ request,
+ template_name="pages/kitchen-sink.html",
+ context={
+ "fixed_content_template": fixed_content_template,
+ "fixed_content_return": fixed_content_return,
+ "passes_fixed_name": passes_fixed_name,
+ "passes_instance_attr_name": passes_instance_attr_name,
+ "passes_self": passes_self,
+ "dataclass_attr_name": dataclass_attr_name,
+ "passes_name_from_parent_context": passes_name_from_parent_context,
+ "name": "Dan", # Provide as an example of parent context.
+ "section_with_heading_and_paragraph": section_with_heading_and_paragraph,
+ "list_section": list_section,
+ },
+ )
diff --git a/laces/test/home/templates/home/home.html b/laces/test/home/templates/home/home.html
deleted file mode 100644
index 1a00229..0000000
--- a/laces/test/home/templates/home/home.html
+++ /dev/null
@@ -1,2 +0,0 @@
-{% load laces %}
-{% component heading %}
diff --git a/laces/test/home/views.py b/laces/test/home/views.py
deleted file mode 100644
index ea1f6fe..0000000
--- a/laces/test/home/views.py
+++ /dev/null
@@ -1,12 +0,0 @@
-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 b2148d7..343cbd3 100644
--- a/laces/test/settings.py
+++ b/laces/test/settings.py
@@ -35,8 +35,7 @@
INSTALLED_APPS = [
"laces",
"laces.test",
- "laces.test.home",
- "laces.test.components",
+ "laces.test.example",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
diff --git a/laces/test/tests/test_components.py b/laces/test/tests/test_components.py
new file mode 100644
index 0000000..ccb041b
--- /dev/null
+++ b/laces/test/tests/test_components.py
@@ -0,0 +1,251 @@
+"""
+Tests for the example components.
+
+These tests are very basic and only ensure that the examples are configured as
+desired. More thorough tests can be found in the `laces.tests.test_components` module.
+"""
+from django.test import SimpleTestCase
+
+from laces.test.example.components import (
+ DataclassAsDictContextComponent,
+ HeadingComponent,
+ ListSectionComponent,
+ ParagraphComponent,
+ PassesFixedNameToContextComponent,
+ PassesInstanceAttributeToContextComponent,
+ PassesNameFromParentContextComponent,
+ PassesSelfToContextComponent,
+ RendersTemplateWithFixedContentComponent,
+ ReturnsFixedContentComponent,
+ SectionWithHeadingAndParagraphComponent,
+)
+
+
+class TestRendersTemplateWithFixedContentComponent(SimpleTestCase):
+ def setUp(self):
+ self.component = RendersTemplateWithFixedContentComponent()
+
+ def test_template_name(self):
+ self.assertEqual(
+ self.component.template_name,
+ "components/hello-world.html",
+ )
+
+ def test_render_html(self):
+ self.assertEqual(
+ self.component.render_html(),
+ "Hello World
\n",
+ )
+
+
+class TestReturnsFixedContentComponent(SimpleTestCase):
+ def setUp(self):
+ self.component = ReturnsFixedContentComponent()
+
+ def test_template_name(self):
+ self.assertFalse(hasattr(self.component, "template_name"))
+
+ def test_render_html(self):
+ self.assertEqual(
+ self.component.render_html(),
+ "Hello World Return
\n",
+ )
+
+
+class TestPassesFixedNameToContextComponent(SimpleTestCase):
+ def setUp(self):
+ self.component = PassesFixedNameToContextComponent()
+
+ def test_template_name(self):
+ self.assertEqual(
+ self.component.template_name,
+ "components/hello-name.html",
+ )
+
+ def test_get_context_data(self):
+ self.assertEqual(
+ self.component.get_context_data(),
+ {"name": "Alice"},
+ )
+
+ def test_render_html(self):
+ self.assertEqual(
+ self.component.render_html(),
+ "Hello Alice
\n",
+ )
+
+
+class TestPassesInstanceAttributeToContextComponent(SimpleTestCase):
+ def setUp(self):
+ self.component = PassesInstanceAttributeToContextComponent(name="Bob")
+
+ def test_template_name(self):
+ self.assertEqual(
+ self.component.template_name,
+ "components/hello-name.html",
+ )
+
+ def test_get_context_data(self):
+ self.assertEqual(
+ self.component.name,
+ "Bob",
+ )
+ self.assertEqual(
+ self.component.get_context_data(),
+ {"name": "Bob"},
+ )
+
+ def test_render_html(self):
+ self.assertEqual(
+ self.component.render_html(),
+ "Hello Bob
\n",
+ )
+
+
+class TestPassesSelfToContextComponent(SimpleTestCase):
+ def setUp(self):
+ self.component = PassesSelfToContextComponent(name="Carol")
+
+ def test_template_name(self):
+ self.assertEqual(
+ self.component.template_name,
+ "components/hello-self-name.html",
+ )
+
+ def test_get_context_data(self):
+ self.assertEqual(
+ self.component.get_context_data(),
+ {"self": self.component},
+ )
+
+ def test_render_html(self):
+ self.assertEqual(
+ self.component.render_html(),
+ "Hello Carol's self
\n",
+ )
+
+
+class TestDataclassAsDictContextComponent(SimpleTestCase):
+ def setUp(self):
+ self.component = DataclassAsDictContextComponent(name="Charlie")
+
+ def test_template_name(self):
+ self.assertEqual(
+ self.component.template_name,
+ "components/hello-name.html",
+ )
+
+ def test_get_context_data(self):
+ self.assertEqual(
+ self.component.name,
+ "Charlie",
+ )
+ self.assertEqual(
+ self.component.get_context_data(),
+ {"name": "Charlie"},
+ )
+
+ def test_render_html(self):
+ self.assertEqual(
+ self.component.render_html(),
+ "Hello Charlie
\n",
+ )
+
+
+class TestPassesNameFromParentContextComponent(SimpleTestCase):
+ def setUp(self):
+ self.component = PassesNameFromParentContextComponent()
+
+ def test_template_name(self):
+ self.assertEqual(
+ self.component.template_name,
+ "components/hello-name.html",
+ )
+
+ def test_get_context_data(self):
+ self.assertEqual(
+ self.component.get_context_data(parent_context={"name": "Dan"}),
+ {"name": "Dan"},
+ )
+
+ def test_render_html(self):
+ self.assertEqual(
+ self.component.render_html(parent_context={"name": "Dan"}),
+ "Hello Dan
\n",
+ )
+
+
+class TestSectionWithHeadingAndParagraphComponent(SimpleTestCase):
+ def setUp(self):
+ self.heading = HeadingComponent(text="Heading")
+ self.content = ParagraphComponent(text="Paragraph")
+ self.component = SectionWithHeadingAndParagraphComponent(
+ heading=self.heading,
+ content=self.content,
+ )
+
+ def test_template_name(self):
+ self.assertEqual(
+ self.component.template_name,
+ "components/section.html",
+ )
+
+ def test_get_context_data(self):
+ self.assertEqual(
+ self.component.get_context_data(),
+ {
+ "heading": self.heading,
+ "content": self.content,
+ },
+ )
+
+ def test_render_html(self):
+ self.assertHTMLEqual(
+ self.component.render_html(),
+ """
+
+ """,
+ )
+
+
+class TestListSection(SimpleTestCase):
+ def setUp(self):
+ self.heading = HeadingComponent(text="Heading")
+ self.item = ParagraphComponent(text="Paragraph")
+ self.component = ListSectionComponent(
+ heading=self.heading,
+ items=[self.item],
+ )
+
+ def test_template_name(self):
+ self.assertEqual(
+ self.component.template_name,
+ "components/list-section.html",
+ )
+
+ def test_get_context_data(self):
+ self.assertEqual(
+ self.component.get_context_data(),
+ {
+ "heading": self.heading,
+ "items": [self.item],
+ },
+ )
+
+ def test_render_html(self):
+ self.assertHTMLEqual(
+ self.component.render_html(),
+ """
+
+ """,
+ )
diff --git a/laces/test/tests/test_views.py b/laces/test/tests/test_views.py
new file mode 100644
index 0000000..9adeeb4
--- /dev/null
+++ b/laces/test/tests/test_views.py
@@ -0,0 +1,53 @@
+"""Tests for the example views that demonstrate the use of components."""
+from http import HTTPStatus
+
+from django.test import RequestFactory, TestCase
+
+from laces.test.example.views import kitchen_sink
+
+
+class TestKitchenSink(TestCase):
+ def test_get(self):
+ factory = RequestFactory()
+ request = factory.get("/")
+
+ response = kitchen_sink(request)
+
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ response_html = response.content.decode("utf-8")
+ self.assertInHTML("Hello World
", response_html)
+ self.assertInHTML("Hello World Return
", response_html)
+ self.assertInHTML("Hello Alice
", response_html)
+ self.assertInHTML("Hello Bob
", response_html)
+ self.assertInHTML("Hello Carol's self
", response_html)
+ self.assertInHTML("Hello Charlie
", response_html)
+ self.assertInHTML("Hello Dan
", response_html)
+ self.assertInHTML("Hello Erin
", response_html)
+ self.assertInHTML(
+ """
+
+ """,
+ response_html,
+ )
+ self.assertInHTML(
+ """
+
+ Heading
+
+ -
+
Item 1
+
+ -
+
Item 2
+
+ -
+
Item 3
+
+
+
+ """,
+ response_html,
+ )
diff --git a/laces/test/urls.py b/laces/test/urls.py
index f586e93..85bfc8f 100644
--- a/laces/test/urls.py
+++ b/laces/test/urls.py
@@ -1,10 +1,8 @@
-from django.contrib import admin
from django.urls import path
-from laces.test.home.views import home
+from laces.test.example.views import kitchen_sink
urlpatterns = [
- path("", home),
- path("django-admin/", admin.site.urls),
+ path("", kitchen_sink),
]
diff --git a/laces/tests/test_components.py b/laces/tests/test_components.py
new file mode 100644
index 0000000..0fd10cc
--- /dev/null
+++ b/laces/tests/test_components.py
@@ -0,0 +1,270 @@
+import os
+import random
+
+from pathlib import Path
+
+from django.conf import settings
+from django.forms.widgets import Media
+from django.template import Context
+from django.test import SimpleTestCase
+from django.utils.html import SafeString
+
+from laces.components import Component, MediaContainer
+
+
+class TestComponent(SimpleTestCase):
+ """Directly test the Component class."""
+
+ def setUp(self):
+ self.component = Component()
+
+ def test_render_html(self):
+ """Test the `render_html` method."""
+ # The default Component does not specify a `template_name` attribute which is
+ # required for `render_html`. So calling the method on the Component class
+ # will raise an error.
+ with self.assertRaises(AttributeError):
+ self.component.render_html()
+
+ def test_get_context_data_parent_context_empty_context(self):
+ """
+ Test the default get_context_data.
+
+ The parent context should not matter, but we use it as it is used in
+ `render_html` (which passes a `Context` object).
+ """
+ result = self.component.get_context_data(parent_context=Context())
+
+ self.assertIsInstance(result, dict)
+ self.assertEqual(result, {})
+
+ def test_media(self):
+ """
+ Test the `media` property.
+
+ The `media` property is added through the `metaclass=MediaDefiningClass`
+ definition.
+
+ """
+ self.assertIsInstance(self.component.media, Media)
+ empty_media = Media()
+ # We need to compare the internal dicts and lists as the `Media` class does not
+ # implement `__eq__`.
+ self.assertEqual(self.component.media._css, empty_media._css)
+ self.assertEqual(self.component.media._js, empty_media._js)
+
+
+class TestComponentSubclasses(SimpleTestCase):
+ """
+ Test the Component class through subclasses.
+
+ Most functionality of the Component class is only unlocked through subclassing and
+ definition of certain attributes (like `template_name`) or overriding of the
+ existing methods. This test class tests the functionality that is unlocked through
+ subclassing.
+ """
+
+ @classmethod
+ def make_example_template_name(cls):
+ return f"example-{random.randint(1000, 10000)}.html"
+
+ @classmethod
+ def get_example_template_name(cls):
+ example_template_name = cls.make_example_template_name()
+ while os.path.exists(example_template_name):
+ example_template_name = cls.make_example_template_name()
+ return example_template_name
+
+ def setUp(self):
+ self.example_template_name = self.get_example_template_name()
+ self.example_template = (
+ Path(settings.PROJECT_DIR) / "templates" / self.example_template_name
+ )
+ # Write content to the template file to ensure it exists.
+ self.set_example_template_content("")
+
+ def set_example_template_content(self, content: str):
+ with open(self.example_template, "w") as f:
+ f.write(content)
+
+ def test_render_html_with_template_name_set(self):
+ """
+ Test `render_html` method with a set `template_name` attribute.
+ """
+
+ # -----------------------------------------------------------------------------
+ class ExampleComponent(Component):
+ template_name = self.example_template_name
+
+ # -----------------------------------------------------------------------------
+
+ self.set_example_template_content("Test")
+
+ result = ExampleComponent().render_html()
+
+ self.assertIsInstance(result, str)
+ self.assertIsInstance(result, SafeString)
+ self.assertEqual(result, "Test")
+
+ def test_render_html_with_template_name_set_and_data_from_get_context_data(self):
+ """
+ Test `render_html` method with `get_context_data` providing data for the
+ context.
+ """
+
+ # -----------------------------------------------------------------------------
+ class ExampleComponent(Component):
+ template_name = self.example_template_name
+
+ def get_context_data(self, parent_context):
+ return {"name": "World"}
+
+ # -----------------------------------------------------------------------------
+
+ self.set_example_template_content("Hello {{ name }}")
+
+ result = ExampleComponent().render_html()
+
+ self.assertEqual(result, "Hello World")
+
+ def test_render_html_when_get_context_data_returns_None(self):
+ """
+ Test `render_html` method when `get_context_data` returns `None`.
+
+ The `render_html` method raises a `TypeError` when `None` is returned from
+ `get_context_method`. This behavior was present when the class was extracted
+ from Wagtail. It is not totally clear why this specific check is needed. By
+ default, the `get_context_data` method provides and empty dict. If an override
+ wanted to `get_context_data` return `None`, it should be expected that no
+ context data is available during rendering. The underlying `template.render`
+ method does not seem to be ok with `None` the provided context.
+ """
+
+ # -----------------------------------------------------------------------------
+ class ExampleComponent(Component):
+ def get_context_data(self, parent_context):
+ return None
+
+ # -----------------------------------------------------------------------------
+
+ with self.assertRaises(TypeError):
+ ExampleComponent().render_html()
+
+ def test_media_defined_through_nested_class(self):
+ """
+ Test the `media` property when defined through a nested class.
+
+ The `media` property is added through the `metaclass=MediaDefiningClass`
+ definition. This test ensures that the `media` property is available when
+ configured through a nested class.
+ """
+
+ # -----------------------------------------------------------------------------
+ class ExampleComponent(Component):
+ class Media:
+ css = {"all": ["example.css"]}
+ js = ["example.js"]
+
+ # -----------------------------------------------------------------------------
+
+ result = ExampleComponent().media
+
+ self.assertIsInstance(result, Media)
+ self.assertEqual(result._css, {"all": ["example.css"]})
+ self.assertEqual(result._js, ["example.js"])
+
+ def tearDown(self):
+ os.remove(path=self.example_template)
+
+
+class TestMediaContainer(SimpleTestCase):
+ """
+ Test the MediaContainer class.
+
+ The `MediaContainer` functionality depends on the `django.forms.widgets.Media`
+ class. The `Media` class provides the logic to combine the media definitions of
+ multiple objects through its `__add__` method. The `MediaContainer` relies on this
+ functionality to provide a `media` property that combines the media definitions of
+ its members.
+
+ See also:
+ https://docs.djangoproject.com/en/4.2/topics/forms/media
+ """
+
+ def setUp(self):
+ self.media_container = MediaContainer()
+
+ def test_empty(self):
+ result = self.media_container.media
+
+ self.assertIsInstance(result, Media)
+ self.assertEqual(result._css, {})
+ self.assertEqual(result._js, [])
+
+ def test_single_member(self):
+ # -----------------------------------------------------------------------------
+ class ExampleClass:
+ media = Media(css={"all": ["example.css"]})
+
+ # -----------------------------------------------------------------------------
+ example = ExampleClass()
+ self.media_container.append(example)
+
+ result = self.media_container.media
+
+ self.assertIsInstance(result, Media)
+ self.assertEqual(result._css, example.media._css)
+ self.assertEqual(result._css, {"all": ["example.css"]})
+ self.assertEqual(result._js, example.media._js)
+ self.assertEqual(result._js, [])
+
+ def test_two_members_of_same_class(self):
+ # -----------------------------------------------------------------------------
+ class ExampleClass:
+ media = Media(css={"all": ["example.css"]}, js=["example.js"])
+
+ # -----------------------------------------------------------------------------
+ example_1 = ExampleClass()
+ example_2 = ExampleClass()
+ self.media_container.append(example_1)
+ self.media_container.append(example_2)
+
+ result = self.media_container.media
+
+ self.assertIsInstance(result, Media)
+ self.assertEqual(result._css, example_1.media._css)
+ self.assertEqual(result._css, {"all": ["example.css"]})
+ self.assertEqual(result._js, example_1.media._js)
+ self.assertEqual(result._js, ["example.js"])
+
+ def test_two_members_of_different_classes(self):
+ # -----------------------------------------------------------------------------
+ class ExampleClass:
+ media = Media(css={"all": ["shared.css"]}, js=["example.js"])
+
+ class OtherExampleClass:
+ media = Media(
+ css={
+ "all": ["other.css", "shared.css"],
+ "print": ["print.css"],
+ },
+ js=["other.js"],
+ )
+
+ # -----------------------------------------------------------------------------
+ example = ExampleClass()
+ self.media_container.append(example)
+ other = OtherExampleClass()
+ self.media_container.append(other)
+
+ result = self.media_container.media
+
+ self.assertIsInstance(result, Media)
+ self.assertEqual(
+ result._css,
+ {
+ "all": ["other.css", "shared.css"],
+ "print": ["print.css"],
+ },
+ )
+ self.assertEqual(result._js, ["example.js", "other.js"])
diff --git a/laces/tests/test_templatetags/test_laces.py b/laces/tests/test_templatetags/test_laces.py
index cc29047..46c58f8 100644
--- a/laces/tests/test_templatetags/test_laces.py
+++ b/laces/tests/test_templatetags/test_laces.py
@@ -1,4 +1,11 @@
-from django.template import Context, Template
+import os
+import random
+
+from pathlib import Path
+from unittest.mock import Mock
+
+from django.conf import settings
+from django.template import Context, Template, TemplateSyntaxError
from django.test import SimpleTestCase
from django.utils.html import format_html
@@ -13,79 +20,306 @@ class TestComponentTag(SimpleTestCase):
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")
- )
+ def setUp(self):
+ self.parent_template = Template("")
+
+ class ExampleComponent(Component):
+ pass
+
+ self.component = ExampleComponent()
+ # Using a mock to be able to check if the `render_html` method is called.
+ self.component.render_html: Mock = Mock(return_value="Rendered HTML")
+
+ def set_parent_template(self, template_string):
+ template_string = "{% load laces %}" + template_string
+ self.parent_template = Template(template_string)
+
+ def render_parent_template_with_context(self, context: dict):
+ return self.parent_template.render(Context(context))
+
+ def assertRenderHTMLCalledWith(self, context: dict):
+ self.assertTrue(self.component.render_html.called_with(Context(context)))
+
+ def test_render_html_return_in_parent_template(self):
+ self.assertEqual(self.component.render_html(), "Rendered HTML")
+ self.set_parent_template("Before {% component my_component %} After")
+
+ result = self.render_parent_template_with_context(
+ {"my_component": self.component},
+ )
+
+ # This matches the return value of the `render_html` method inserted into the
+ # parent template.
+ self.assertEqual(result, "Before Rendered HTML After")
+
+ def test_render_html_return_is_escaped(self):
+ self.component.render_html.return_value = (
+ "Look, I'm running with scissors! 8< 8< 8<"
+ )
+ self.set_parent_template("{% component my_component %}")
+
+ result = self.render_parent_template_with_context(
+ {"my_component": self.component},
+ )
+
+ self.assertEqual(
+ result,
+ "Look, I'm running with scissors! 8< 8< 8<",
+ )
+
+ def test_render_html_return_not_escaped_when_formatted_html(self):
+ self.component.render_html.return_value = format_html("My component
")
+ self.set_parent_template("{% component my_component %}")
+
+ result = self.render_parent_template_with_context(
+ {"my_component": self.component},
+ )
+
+ self.assertEqual(result, "My component
")
+
+ def test_render_html_return_not_escaped_when_actually_rendered_template(self):
+ example_template_name = f"example-{random.randint(1000, 10000)}.html"
+ example_template = (
+ Path(settings.PROJECT_DIR) / "templates" / example_template_name
+ )
+ with open(example_template, "w") as f:
+ f.write("My component
")
+
+ # -----------------------------------------------------------------------------
+ class RealExampleComponent(Component):
+ template_name = example_template_name
+
+ # -----------------------------------------------------------------------------
+ component = RealExampleComponent()
+ self.set_parent_template("{% component my_component %}")
+
+ result = self.render_parent_template_with_context(
+ {"my_component": component},
+ )
+
+ self.assertEqual(result, "My component
")
+ os.remove(example_template)
+
+ def test_render_html_parent_context_when_only_component_in_context(self):
+ self.set_parent_template("{% component my_component %}")
+
+ self.render_parent_template_with_context({"my_component": self.component})
+
+ self.assertTrue(self.component.render_html.called)
+ # The component itself is not included in the context that is passed to the
+ # `render_html` method.
+ self.assertRenderHTMLCalledWith({})
+
+ def test_render_html_parent_context_when_other_variable_in_context(self):
+ self.set_parent_template("{% component my_component %}")
+
+ self.render_parent_template_with_context(
+ {
+ "my_component": self.component,
+ "test": "something",
+ }
+ )
+
+ self.assertRenderHTMLCalledWith({"test": "something"})
+
+ def test_render_html_parent_context_when_with_block_sets_extra_context(self):
+ self.set_parent_template(
+ "{% with test='something' %}{% component my_component %}{% endwith %}"
+ )
+
+ self.render_parent_template_with_context({"my_component": self.component})
+
+ self.assertRenderHTMLCalledWith({"test": "something"})
+
+ def test_render_html_parent_context_when_with_keyword_sets_extra_context(self):
+ self.set_parent_template("{% component my_component with test='something' %}")
+
+ self.render_parent_template_with_context({"my_component": self.component})
+
+ self.assertRenderHTMLCalledWith({"test": "something"})
+
+ def test_render_html_parent_context_when_with_only_keyword_limits_extra_context(
+ self,
+ ):
+ self.set_parent_template(
+ "{% component my_component with test='nothing else' only %}"
+ )
+
+ self.render_parent_template_with_context(
+ {
+ "my_component": self.component,
+ "other": "something else",
+ }
+ )
- template = Template(
- "{% load laces %}{% with first_name='Kilroy' %}{% component my_component %}{% endwith %}"
+ # The `other` variable from the parent's rendering context is not included in
+ # the context that is passed to the `render_html` method. The `test` variable,
+ # that was defined with the with-keyword, is present though. Both of these
+ # effects come form the `only` keyword.
+ self.assertRenderHTMLCalledWith({"test": "nothing else"})
+
+ def test_render_html_parent_context_when_with_block_overrides_context(self):
+ self.set_parent_template(
+ "{% with test='something else' %}{% 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' %}"
+ self.render_parent_template_with_context(
+ {
+ "my_component": self.component,
+ "test": "something",
+ }
)
- 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 %}"
+ self.assertRenderHTMLCalledWith({"test": "something else"})
+
+ def test_render_html_parent_context_when_with_keyword_overrides_context(self):
+ self.set_parent_template(
+ "{% component my_component with test='something else' %}"
)
- 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
")
+ self.render_parent_template_with_context(
+ {
+ "my_component": self.component,
+ "test": "something",
+ }
+ )
+
+ self.assertRenderHTMLCalledWith({"test": "something else"})
+
+ def test_render_html_parent_context_when_with_keyword_overrides_with_block(self):
+ self.set_parent_template(
+ """
+ {% with test='something' %}
+ {% component my_component with test='something else' %}
+ {% endwith %}
+ """
+ )
+
+ self.render_parent_template_with_context({"my_component": self.component})
+
+ self.assertRenderHTMLCalledWith({"test": "something else"})
- class MyNonComponent:
+ def test_fallback_render_method_arg_true_and_object_with_render_method(self):
+ # -----------------------------------------------------------------------------
+ class ExampleNonComponentWithRenderMethod:
def render(self):
- return format_html("I am not a component
")
+ return "Rendered non-component"
+
+ # -----------------------------------------------------------------------------
+ non_component = ExampleNonComponentWithRenderMethod()
+ self.set_parent_template(
+ "{% component my_non_component fallback_render_method=True %}"
+ )
+
+ result = self.render_parent_template_with_context(
+ {"my_non_component": non_component},
+ )
+
+ self.assertEqual(result, "Rendered non-component")
+
+ def test_fallback_render_method_arg_true_but_object_without_render_method(self):
+ # -----------------------------------------------------------------------------
+ class ExampleNonComponentWithoutRenderMethod:
+ pass
+
+ # -----------------------------------------------------------------------------
+ non_component = ExampleNonComponentWithoutRenderMethod()
+ self.set_parent_template(
+ "{% component my_non_component fallback_render_method=True %}"
+ )
- 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()}))
+ self.render_parent_template_with_context(
+ {"my_non_component": non_component},
+ )
+
+ def test_no_fallback_render_method_arg_and_object_without_render_method(self):
+ # -----------------------------------------------------------------------------
+ class ExampleNonComponentWithoutRenderMethod:
+ def __repr__(self):
+ return ""
+
+ # -----------------------------------------------------------------------------
+ non_component = ExampleNonComponentWithoutRenderMethod()
+ self.set_parent_template("{% component my_non_component %}")
+
+ with self.assertRaises(ValueError) as cm:
+ self.render_parent_template_with_context(
+ {"my_non_component": non_component},
+ )
+ self.assertEqual(
+ str(cm.exception),
+ "Cannot render as a component",
+ )
- template = Template(
- "{% load laces %}{% component my_component fallback_render_method=True %}"
+ def test_as_keyword_stores_render_html_return_as_variable(self):
+ self.set_parent_template(
+ "{% component my_component as my_var %}The result was: {{ my_var }}"
)
- 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<"
+ result = self.render_parent_template_with_context(
+ {"my_component": self.component},
+ )
+
+ self.assertEqual(result, "The result was: Rendered HTML")
+
+ def test_as_keyword_without_variable_name(self):
+ # The template is already parsed when the parent template is set. This is the
+ # moment where the parsing error is raised.
+ with self.assertRaises(TemplateSyntaxError) as cm:
+ self.set_parent_template("{% component my_component as %}")
- 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<
"
+ str(cm.exception),
+ "'component' tag with 'as' must be followed by a variable name",
)
- def test_error_on_rendering_non_component(self):
- template = Template("{% load laces %}{% component my_component %}
")
+ def test_autoescape_off_block_can_disable_escaping_of_render_html_return(self):
+ self.component.render_html.return_value = (
+ "Look, I'm running with scissors! 8< 8< 8<"
+ )
+ self.set_parent_template(
+ "{% autoescape off %}{% component my_component %}{% endautoescape %}"
+ )
- with self.assertRaises(ValueError) as cm:
- template.render(Context({"my_component": "hello"}))
- self.assertEqual(str(cm.exception), "Cannot render 'hello' as a component")
+ result = self.render_parent_template_with_context(
+ {"my_component": self.component},
+ )
+
+ self.assertEqual(
+ result,
+ "Look, I'm running with scissors! 8< 8< 8<",
+ )
+
+ def test_parsing_no_arguments(self):
+ # The template is already parsed when the parent template is set. This is the
+ # moment where the parsing error is raised.
+ with self.assertRaises(TemplateSyntaxError) as cm:
+ self.set_parent_template("{% component %}")
+
+ self.assertEqual(
+ str(cm.exception),
+ "'component' tag requires at least one argument, the component object",
+ )
- def test_render_as_var(self):
- class MyComponent(Component):
- def render_html(self, parent_context):
- return format_html("I am a component
")
+ def test_parsing_unknown_kwarg(self):
+ # The template is already parsed when the parent template is set. This is the
+ # moment where the parsing error is raised.
+ with self.assertRaises(TemplateSyntaxError) as cm:
+ self.set_parent_template("{% component my_component unknown_kwarg=True %}")
- template = Template(
- "{% load laces %}{% component my_component as my_html %}The result was: {{ my_html }}"
+ self.assertEqual(
+ str(cm.exception),
+ "'component' tag only accepts 'fallback_render_method' as a keyword argument",
+ )
+
+ def test_parsing_unknown_bit(self):
+ # The template is already parsed when the parent template is set. This is the
+ # moment where the parsing error is raised.
+ with self.assertRaises(TemplateSyntaxError) as cm:
+ self.set_parent_template("{% component my_component unknown_bit %}")
+
+ self.assertEqual(
+ str(cm.exception),
+ "'component' tag received an unknown argument: 'unknown_bit'",
)
- html = template.render(Context({"my_component": MyComponent()}))
- self.assertEqual(html, "The result was: I am a component
")
diff --git a/pyproject.toml b/pyproject.toml
index 9cf8bb1..e94b429 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,12 +33,14 @@ dependencies = [
[project.optional-dependencies]
testing = [
"dj-database-url==2.1.0",
- "pre-commit==3.4.0"
+ "pre-commit==3.4.0",
+ "coverage==7.3.4",
]
ci = [
"tox==4.11.3",
"tox-gh-actions==3.1.3",
# Allow use of pyenv for virtual environments. To enable you need to set `VIRTUALENV_DISCOVERY=pyenv` in the shell.
+ # This is useful to help tox find the correct python version when using pyenv.
"virtualenv-pyenv==0.4.0"
]
diff --git a/testmanage.py b/testmanage.py
old mode 100644
new mode 100755
diff --git a/tox.ini b/tox.ini
index cfc4498..2049123 100644
--- a/tox.ini
+++ b/tox.ini
@@ -18,7 +18,15 @@ DB =
[testenv]
install_command = pip install -e ".[testing]" -U {opts} {packages}
-commands = coverage run testmanage.py test --deprecation all {posargs: -v 2}
+commands =
+ # Run coverage in append mode so that we get a combined report over all environments.
+ # This can not be combined with parallel mode.
+ # This only affects local working, because each env is run on a different runner in CI.
+ coverage run -a testmanage.py test --deprecation all {posargs: -v 2}
+
+commands_post =
+ # The report is converted to json to be uploaded to codecov.
+ coverage json --data-file .coverage -o .coverage.json
basepython =
python3.8: python3.8
@@ -27,6 +35,7 @@ basepython =
python3.11: python3.11
deps =
+ # Coverage is required here (even though it's in pyproject.toml) to make it work on GitHub Actions
coverage
django3.2: Django>=3.2,<4.0