refactor: Assign content of file from Component.template_file to Component.template (#880)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2025-01-07 19:34:34 +01:00 committed by GitHub
parent 8e2428ebd0
commit 1e4b556b4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 399 additions and 186 deletions

View file

@ -8,8 +8,10 @@
them to their own files, and link the JS/CSS files with `Component.js_file` and `Component.css_file`. them to their own files, and link the JS/CSS files with `Component.js_file` and `Component.css_file`.
Even when you specify the JS/CSS with `Component.js_file` or `Component.css_file`, then you can still Even when you specify the JS/CSS with `Component.js_file` or `Component.css_file`, then you can still
access the content under `Component.js/css` - behind the scenes, the content of the JS/CSS files access the content under `Component.js` or `Component.css` - behind the scenes, the content of the JS/CSS files
will be set to `Component.js/css` upon first access. will be set to `Component.js` / `Component.css` upon first access.
The same applies to `Component.template_file`, which will populate `Component.template` upon first access.
With this change, the role of `Component.js/css` and the JS/CSS in `Component.Media` has changed: With this change, the role of `Component.js/css` and the JS/CSS in `Component.Media` has changed:
@ -32,7 +34,7 @@
every time you render the component (e.g. with `Component.render()`) every time you render the component (e.g. with `Component.render()`)
- The new `id` is available only during render, so e.g. from within `get_context_data()` - The new `id` is available only during render, so e.g. from within `get_context_data()`
- Component's HTML / CSS / JS are now resolved and loaded lazily. That is, if you specify `template_name`, - Component's HTML / CSS / JS are now resolved and loaded lazily. That is, if you specify `template_name`/`template_file`,
`js_file`, `css_file`, or `Media.js/css`, the file paths will be resolved only once you: `js_file`, `css_file`, or `Media.js/css`, the file paths will be resolved only once you:
1. Try to access component's HTML / CSS / JS, or 1. Try to access component's HTML / CSS / JS, or
@ -40,6 +42,8 @@
Read more on [Accessing component's HTML / JS / CSS](https://EmilStenstrom.github.io/django-components/0.124/concepts/fundamentals/defining_js_css_html_files/#customize-how-paths-are-rendered-into-html-tags). Read more on [Accessing component's HTML / JS / CSS](https://EmilStenstrom.github.io/django-components/0.124/concepts/fundamentals/defining_js_css_html_files/#customize-how-paths-are-rendered-into-html-tags).
- The [Signals](https://docs.djangoproject.com/en/5.1/topics/signals/) emitted by or during the use of django-components are now documented, together the `template_rendered` signal.
## v0.123 ## v0.123
#### Fix #### Fix

View file

@ -339,7 +339,7 @@ these file paths will be resolved only once you either:
1. Access any of the following attributes on the component: 1. Access any of the following attributes on the component:
- [`media`](../../reference/api.md#django_components.Component.media), - [`media`](../../reference/api.md#django_components.Component.media),
[`template`](../../reference/api.md#django_components.Component.template), [`template`](../../reference/api.md#django_components.Component.template),
[`template_file`](../../reference/api.md#django_components.Component.template_file), [`template_file`](../../reference/api.md#django_components.Component.template_file),
[`js`](../../reference/api.md#django_components.Component.js), [`js`](../../reference/api.md#django_components.Component.js),
@ -398,7 +398,7 @@ print(Calendar.css)
If you need to dynamically change these media files, consider instead defining multiple Components. If you need to dynamically change these media files, consider instead defining multiple Components.
Modifying these files AFTER the component has been loaded at best does nothing. However, this is Modifying these files AFTER the component has been loaded at best does nothing. However, this is
an untested behavior. an untested behavior, which may lead to unexpected errors.
## Accessing component's Media files ## Accessing component's Media files

View file

@ -551,6 +551,22 @@ def gen_reference_templatevars():
f.write(f"::: {ComponentVars.__module__}.{ComponentVars.__name__}.{field}\n\n") f.write(f"::: {ComponentVars.__module__}.{ComponentVars.__name__}.{field}\n\n")
# NOTE: Unlike other references, the API of Signals is not yet codified (AKA source of truth defined
# as Python code). Instead, we manually list all signals that are sent by django-components.
def gen_reference_signals():
"""
Generate documentation for all [Django Signals](https://docs.djangoproject.com/en/5.1/ref/signals) that are
send by or during the use of django-components.
"""
preface = "<!-- Autogenerated by reference.py -->\n\n"
preface += (root / "docs/templates/reference_signals.md").read_text()
out_file = root / "docs/reference/signals.md"
out_file.parent.mkdir(parents=True, exist_ok=True)
with out_file.open("w", encoding="utf-8") as f:
f.write(preface + "\n\n")
def _list_urls(urlpatterns: Sequence[Union[URLPattern, URLResolver]], prefix=""): def _list_urls(urlpatterns: Sequence[Union[URLPattern, URLResolver]], prefix=""):
"""Recursively extract all URLs and their associated views from Django's urlpatterns""" """Recursively extract all URLs and their associated views from Django's urlpatterns"""
urls: List[str] = [] urls: List[str] = []
@ -732,6 +748,7 @@ def gen_reference():
gen_reference_commands() gen_reference_commands()
gen_reference_templatetags() gen_reference_templatetags()
gen_reference_templatevars() gen_reference_templatevars()
gen_reference_signals()
# This is run when `gen-files` plugin is run in mkdocs.yml # This is run when `gen-files` plugin is run in mkdocs.yml

37
docs/templates/reference_signals.md vendored Normal file
View file

@ -0,0 +1,37 @@
# Signals
Below are the signals that are sent by or during the use of django-components.
## template_rendered
Django's [`template_rendered`](https://docs.djangoproject.com/en/5.1/ref/signals/#template-rendered) signal.
This signal is sent when a template is rendered.
Django-components triggers this signal when a component is rendered. If there are nested components,
the signal is triggered for each component.
Import from django as `django.test.signals.template_rendered`.
```python
from django.test.signals import template_rendered
# Setup a callback function
def my_callback(sender, **kwargs):
...
template_rendered.connect(my_callback)
class MyTable(Component):
template = """
<table>
<tr>
<th>Header</th>
</tr>
<tr>
<td>Cell</td>
</tr>
"""
# This will trigger the signal
MyTable().render()
```

View file

@ -31,6 +31,7 @@ from django.template.base import NodeList, Template, TextNode
from django.template.context import Context, RequestContext from django.template.context import Context, RequestContext
from django.template.loader import get_template from django.template.loader import get_template
from django.template.loader_tags import BLOCK_CONTEXT_KEY from django.template.loader_tags import BLOCK_CONTEXT_KEY
from django.test.signals import template_rendered
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from django.views import View from django.views import View
@ -227,8 +228,15 @@ class Component(
""" """
Filepath to the Django template associated with this component. Filepath to the Django template associated with this component.
The filepath must be relative to either the file where the component class was defined, The filepath must be either:
or one of the roots of `STATIFILES_DIRS`.
- Relative to the directory where the Component's Python file is defined.
- Relative to one of the component directories, as set by
[`COMPONENTS.dirs`](../settings.md#django_components.app_settings.ComponentsSettings.dirs)
or
[`COMPONENTS.app_dirs`](../settings.md#django_components.app_settings.ComponentsSettings.app_dirs)
(e.g. `<root>/components/`).
- Relative to the template directories, as set by Django's `TEMPLATES` setting (e.g. `<root>/templates/`).
Only one of [`template_file`](../api#django_components.Component.template_file), Only one of [`template_file`](../api#django_components.Component.template_file),
[`get_template_name`](../api#django_components.Component.get_template_name), [`get_template_name`](../api#django_components.Component.get_template_name),
@ -327,6 +335,16 @@ class Component(
""" """
Main JS associated with this component as file path. Main JS associated with this component as file path.
The filepath must be either:
- Relative to the directory where the Component's Python file is defined.
- Relative to one of the component directories, as set by
[`COMPONENTS.dirs`](../settings.md#django_components.app_settings.ComponentsSettings.dirs)
or
[`COMPONENTS.app_dirs`](../settings.md#django_components.app_settings.ComponentsSettings.app_dirs)
(e.g. `<root>/components/`).
- Relative to the staticfiles directories, as set by Django's `STATICFILES_DIRS` setting (e.g. `<root>/static/`).
When you create a Component class with `js_file`, these will happen: When you create a Component class with `js_file`, these will happen:
1. If the file path is relative to the directory where the component's Python file is, 1. If the file path is relative to the directory where the component's Python file is,
@ -374,6 +392,16 @@ class Component(
""" """
Main CSS associated with this component as file path. Main CSS associated with this component as file path.
The filepath must be either:
- Relative to the directory where the Component's Python file is defined.
- Relative to one of the component directories, as set by
[`COMPONENTS.dirs`](../settings.md#django_components.app_settings.ComponentsSettings.dirs)
or
[`COMPONENTS.app_dirs`](../settings.md#django_components.app_settings.ComponentsSettings.app_dirs)
(e.g. `<root>/components/`).
- Relative to the staticfiles directories, as set by Django's `STATICFILES_DIRS` setting (e.g. `<root>/static/`).
When you create a Component class with `css_file`, these will happen: When you create a Component class with `css_file`, these will happen:
1. If the file path is relative to the directory where the component's Python file is, 1. If the file path is relative to the directory where the component's Python file is,
@ -628,50 +656,52 @@ class Component(
return ctx.is_filled return ctx.is_filled
# NOTE: When the template is taken from a file (AKA specified via `template_file`), # NOTE: We cache the Template instance. When the template is taken from a file
# then we leverage Django's template caching. This means that the same instance # via `get_template_name`, then we leverage Django's template caching with `get_template()`.
# of Template is reused. This is important to keep in mind, because the implication # Otherwise, we use our own `cached_template()` to cache the template.
# is that we should treat Templates AND their nodelists as IMMUTABLE. #
# This is important to keep in mind, because the implication is that we should
# treat Templates AND their nodelists as IMMUTABLE.
def _get_template(self, context: Context) -> Template: def _get_template(self, context: Context) -> Template:
# Resolve template name template_name = self.get_template_name(context)
template_file = self.template_file # TODO_REMOVE_IN_V1 - Remove `self.get_template_string` in v1
if self.template_file is not None: template_getter = getattr(self, "get_template_string", self.get_template)
if self.get_template_name(context) is not None: template_body = template_getter(context)
raise ImproperlyConfigured(
"Received non-null value from both 'template_file' and 'get_template_name' in"
f" Component {type(self).__name__}. Only one of the two must be set."
)
else:
template_file = self.get_template_name(context)
# Resolve template str # `get_template_name()`, `get_template()`, and `template` are mutually exclusive
template_input = self.template #
if self.template is not None: # Note that `template` and `template_name` are also mutually exclusive, but this
if self.get_template(context) is not None: # is checked when lazy-loading the template from `template_name`. So if user specified
raise ImproperlyConfigured( # `template_name`, then `template` will be populated with the content of that file.
"Received non-null value from both 'template' and 'get_template' in" if self.template is not None and template_name is not None:
f" Component {type(self).__name__}. Only one of the two must be set."
)
else:
# TODO_REMOVE_IN_V1 - Remove `self.get_template_string` in v1
template_getter = getattr(self, "get_template_string", self.get_template)
template_input = template_getter(context)
if template_file is not None and template_input is not None:
raise ImproperlyConfigured( raise ImproperlyConfigured(
f"Received both 'template_file' and 'template' in Component {type(self).__name__}." "Received non-null value from both 'template/template_name' and 'get_template_name' in"
" Only one of the two must be set." f" Component {type(self).__name__}. Only one of the two must be set."
)
if self.template is not None and template_body is not None:
raise ImproperlyConfigured(
"Received non-null value from both 'template/template_name' and 'get_template' in"
f" Component {type(self).__name__}. Only one of the two must be set."
)
if template_name is not None and template_body is not None:
raise ImproperlyConfigured(
"Received non-null value from both 'get_template_name' and 'get_template' in"
f" Component {type(self).__name__}. Only one of the two must be set."
) )
if template_file is not None: if template_name is not None:
return get_template(template_file).template return get_template(template_name).template
elif template_input is not None: template_body = template_body if template_body is not None else self.template
if template_body is not None:
# We got template string, so we convert it to Template # We got template string, so we convert it to Template
if isinstance(template_input, str): if isinstance(template_body, str):
template: Template = cached_template(template_input) template: Template = cached_template(
template_string=template_body,
name=self.template_file,
)
else: else:
template = template_input template = template_body
return template return template
@ -1022,6 +1052,8 @@ class Component(
): ):
self.on_render_before(context, template) self.on_render_before(context, template)
# Emit signal that the template is about to be rendered
template_rendered.send(sender=self, template=self, context=context)
# Get the component's HTML # Get the component's HTML
html_content = template.render(context) html_content = template.render(context)

View file

@ -2,11 +2,13 @@ import os
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Protocol, Tuple, Type, Union, cast from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Protocol, Tuple, Type, Union, cast
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media as MediaCls from django.forms.widgets import Media as MediaCls
from django.template import Template, TemplateDoesNotExist
from django.template.loader import get_template
from django.utils.safestring import SafeData from django.utils.safestring import SafeData
from django_components.util.loader import get_component_dirs, resolve_file from django_components.util.loader import get_component_dirs, resolve_file
@ -377,14 +379,24 @@ def _resolve_media(comp_cls: Type["Component"], comp_media: ComponentMedia) -> N
# as relative to the directory where the component class is defined. # as relative to the directory where the component class is defined.
_resolve_component_relative_files(comp_cls, comp_media, comp_dirs=comp_dirs) _resolve_component_relative_files(comp_cls, comp_media, comp_dirs=comp_dirs)
# If the component defined `js_file` or `css_file`, instead of `js`/`css` resolve them now. # If the component defined `template_file`, `js_file` or `css_file`, instead of `template`/`js`/`css`,
# Effectively, even if the Component class defined `js_file`, at "runtime" the `js` attribute # we resolve them now.
# Effectively, even if the Component class defined `js_file` (or others), at "runtime" the `js` attribute
# will be set to the content of the file. # will be set to the content of the file.
comp_media.js = _get_static_asset( # So users can access `Component.js` even if they defined `Component.js_file`.
comp_cls, comp_media, inlined_attr="js", file_attr="js_file", comp_dirs=comp_dirs comp_media.template = _get_asset(
comp_cls,
comp_media,
inlined_attr="template",
file_attr="template_file",
comp_dirs=comp_dirs,
type="template",
) )
comp_media.css = _get_static_asset( comp_media.js = _get_asset(
comp_cls, comp_media, inlined_attr="css", file_attr="css_file", comp_dirs=comp_dirs comp_cls, comp_media, inlined_attr="js", file_attr="js_file", comp_dirs=comp_dirs, type="static"
)
comp_media.css = _get_asset(
comp_cls, comp_media, inlined_attr="css", file_attr="css_file", comp_dirs=comp_dirs, type="static"
) )
media_cls = comp_media.media_class or MediaCls media_cls = comp_media.media_class or MediaCls
@ -656,12 +668,13 @@ def _get_dir_path_from_component_path(
return comp_dir_path_abs, comp_dir_path_rel return comp_dir_path_abs, comp_dir_path_rel
def _get_static_asset( def _get_asset(
comp_cls: Type["Component"], comp_cls: Type["Component"],
comp_media: ComponentMedia, comp_media: ComponentMedia,
inlined_attr: str, inlined_attr: str,
file_attr: str, file_attr: str,
comp_dirs: List[Path], comp_dirs: List[Path],
type: Literal["template", "static"],
) -> Optional[str]: ) -> Optional[str]:
""" """
In case of Component's JS or CSS, one can either define that as "inlined" or as a file. In case of Component's JS or CSS, one can either define that as "inlined" or as a file.
@ -698,9 +711,18 @@ def _get_static_asset(
if asset_file is not None: if asset_file is not None:
# Check if the file is in one of the components' directories # Check if the file is in one of the components' directories
full_path = resolve_file(asset_file, comp_dirs) full_path = resolve_file(asset_file, comp_dirs)
# If not, check if it's in the static files
if full_path is None: if full_path is None:
full_path = finders.find(asset_file) # If not, check if it's in the static files
if type == "static":
full_path = finders.find(asset_file)
# Or in the templates
elif type == "template":
try:
template: Template = get_template(asset_file)
full_path = template.origin.name
except TemplateDoesNotExist:
pass
if full_path is None: if full_path is None:
# NOTE: The short name, e.g. `js` or `css` is used in the error message for convenience # NOTE: The short name, e.g. `js` or `css` is used in the error message for convenience

View file

@ -0,0 +1 @@
Variable: <strong>{{ variable }}</strong>

View file

@ -20,29 +20,7 @@ setup_test_config({"autodiscover": False})
# "Main media" refer to the HTML, JS, and CSS set on the Component class itself # "Main media" refer to the HTML, JS, and CSS set on the Component class itself
# (as opposed via the `Media` class). These have special handling in the Component. # (as opposed via the `Media` class). These have special handling in the Component.
class MainMediaTest(BaseTestCase): class MainMediaTest(BaseTestCase):
def test_html_inlined(self): def test_html_js_css_inlined(self):
class InlineHTMLComponent(Component):
template = "<div class='inline'>Hello Inline</div>"
self.assertHTMLEqual(
InlineHTMLComponent.render(),
'<div class="inline" data-djc-id-a1bc3e>Hello Inline</div>',
)
def test_html_filepath(self):
class Test(Component):
template_file = "simple_template.html"
rendered = Test.render(context={"variable": "test"})
self.assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-a1bc3e>test</strong>
""",
)
def test_js_css_inlined(self):
class TestComponent(Component): class TestComponent(Component):
template = """ template = """
{% load component_tags %} {% load component_tags %}
@ -77,22 +55,37 @@ class MainMediaTest(BaseTestCase):
rendered, rendered,
) )
# Check that the HTML / JS / CSS can be accessed on the component class
self.assertEqual(
TestComponent.template,
"""
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
<div class='html-css-only'>Content</div>
""",
)
self.assertEqual(
TestComponent.css,
".html-css-only { color: blue; }",
)
self.assertEqual(
TestComponent.js,
"console.log('HTML and JS only');",
)
@override_settings( @override_settings(
STATICFILES_DIRS=[ STATICFILES_DIRS=[
os.path.join(Path(__file__).resolve().parent, "static_root"), os.path.join(Path(__file__).resolve().parent, "static_root"),
], ],
) )
def test_js_css_filepath_rel_to_component(self): def test_html_js_css_filepath_rel_to_component(self):
from tests.test_app.components.app_lvl_comp.app_lvl_comp import AppLvlCompComponent from tests.test_app.components.app_lvl_comp.app_lvl_comp import AppLvlCompComponent
class TestComponent(AppLvlCompComponent): class TestComponent(AppLvlCompComponent):
template_file = None pass
template = """
{% load component_tags %} registry.register("test", TestComponent)
{% component_js_dependencies %}
{% component_css_dependencies %}
<div class='html-css-only'>Content</div>
"""
self.assertIn( self.assertIn(
".html-css-only {\n color: blue;\n}", ".html-css-only {\n color: blue;\n}",
@ -103,10 +96,23 @@ class MainMediaTest(BaseTestCase):
TestComponent.js, TestComponent.js,
) )
rendered = TestComponent.render(kwargs={"variable": "test"}) rendered_raw = Template(
"""
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component "test" variable="test" / %}
"""
).render(Context())
rendered = render_dependencies(rendered_raw)
self.assertInHTML( self.assertInHTML(
'<div class="html-css-only" data-djc-id-a1bc3e>Content</div>', """
<form data-djc-id-a1bc41 method="post">
<input name="variable" type="text" value="test"/>
<input type="submit"/>
</form>
""",
rendered, rendered,
) )
self.assertInHTML( self.assertInHTML(
@ -118,22 +124,46 @@ class MainMediaTest(BaseTestCase):
rendered, rendered,
) )
# Check that the HTML / JS / CSS can be accessed on the component class
self.assertEqual(
TestComponent.template,
(
'<form method="post">\n'
" {% csrf_token %}\n"
' <input type="text" name="variable" value="{{ variable }}">\n'
' <input type="submit">\n'
"</form>\n"
),
)
self.assertEqual(TestComponent.css, ".html-css-only {\n" " color: blue;\n" "}\n")
self.assertEqual(
TestComponent.js,
'console.log("JS file");\n',
)
@override_settings( @override_settings(
STATICFILES_DIRS=[ STATICFILES_DIRS=[
os.path.join(Path(__file__).resolve().parent, "static_root"), os.path.join(Path(__file__).resolve().parent, "static_root"),
], ],
) )
def test_js_css_filepath_from_static(self): def test_html_js_css_filepath_from_static(self):
class TestComponent(Component): class TestComponent(Component):
template = """ template_file = "test_app_simple_template.html"
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
<div class='html-css-only'>Content</div>
"""
css_file = "style.css" css_file = "style.css"
js_file = "script.js" js_file = "script.js"
def get_context_data(self, variable):
return {
"variable": variable,
}
registry.register("test", TestComponent)
self.assertIn(
"Variable: <strong>{{ variable }}</strong>",
TestComponent.template,
)
self.assertIn( self.assertIn(
".html-css-only {\n color: blue;\n}", ".html-css-only {\n color: blue;\n}",
TestComponent.css, TestComponent.css,
@ -143,10 +173,18 @@ class MainMediaTest(BaseTestCase):
TestComponent.js, TestComponent.js,
) )
rendered = TestComponent.render() rendered_raw = Template(
"""
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component "test" variable="test" / %}
"""
).render(Context())
rendered = render_dependencies(rendered_raw)
self.assertInHTML( self.assertIn(
'<div class="html-css-only" data-djc-id-a1bc3e>Content</div>', "Variable: <strong data-djc-id-a1bc41>test</strong>",
rendered, rendered,
) )
self.assertInHTML( self.assertInHTML(
@ -158,22 +196,38 @@ class MainMediaTest(BaseTestCase):
rendered, rendered,
) )
# Check that the HTML / JS / CSS can be accessed on the component class
self.assertEqual(
TestComponent.template,
"Variable: <strong>{{ variable }}</strong>\n",
)
self.assertEqual(
TestComponent.css,
(
"/* Used in `MainMediaTest` tests in `test_component_media.py` */\n"
".html-css-only {\n"
" color: blue;\n"
"}"
),
)
self.assertEqual(
TestComponent.js,
(
"/* Used in `MainMediaTest` tests in `test_component_media.py` */\n"
'console.log("HTML and JS only");\n'
),
)
@override_settings( @override_settings(
STATICFILES_DIRS=[ STATICFILES_DIRS=[
os.path.join(Path(__file__).resolve().parent, "static_root"), os.path.join(Path(__file__).resolve().parent, "static_root"),
], ],
) )
def test_js_css_filepath_lazy_loaded(self): def test_html_js_css_filepath_lazy_loaded(self):
from tests.test_app.components.app_lvl_comp.app_lvl_comp import AppLvlCompComponent from tests.test_app.components.app_lvl_comp.app_lvl_comp import AppLvlCompComponent
class TestComponent(AppLvlCompComponent): class TestComponent(AppLvlCompComponent):
template_file = None pass
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
<div class='html-css-only'>Content</div>
"""
# NOTE: Since this is a subclass, actual CSS is defined on the parent class, and thus # NOTE: Since this is a subclass, actual CSS is defined on the parent class, and thus
# the corresponding ComponentMedia instance is also on the parent class. # the corresponding ComponentMedia instance is also on the parent class.
@ -189,15 +243,40 @@ class MainMediaTest(BaseTestCase):
# Access the property to load the CSS # Access the property to load the CSS
_ = TestComponent.css _ = TestComponent.css
self.assertHTMLEqual( self.assertEqual(
AppLvlCompComponent._component_media.css, # type: ignore[attr-defined] AppLvlCompComponent._component_media.css, # type: ignore[attr-defined]
".html-css-only { color: blue; }", (".html-css-only {\n" " color: blue;\n" "}\n"),
) )
self.assertEqual( self.assertEqual(
AppLvlCompComponent._component_media.css_file, # type: ignore[attr-defined] AppLvlCompComponent._component_media.css_file, # type: ignore[attr-defined]
"app_lvl_comp/app_lvl_comp.css", "app_lvl_comp/app_lvl_comp.css",
) )
# Also check JS and HTML while we're at it
self.assertEqual(
AppLvlCompComponent._component_media.template, # type: ignore[attr-defined]
(
'<form method="post">\n'
" {% csrf_token %}\n"
' <input type="text" name="variable" value="{{ variable }}">\n'
' <input type="submit">\n'
"</form>\n"
),
)
self.assertEqual(
AppLvlCompComponent._component_media.template_file, # type: ignore[attr-defined]
"app_lvl_comp/app_lvl_comp.html",
)
self.assertEqual(
AppLvlCompComponent._component_media.js, # type: ignore[attr-defined]
'console.log("JS file");\n',
)
self.assertEqual(
AppLvlCompComponent._component_media.js_file, # type: ignore[attr-defined]
"app_lvl_comp/app_lvl_comp.js",
)
def test_html_variable(self): def test_html_variable(self):
class VariableHTMLComponent(Component): class VariableHTMLComponent(Component):
def get_template(self, context): def get_template(self, context):

88
tests/test_signals.py Normal file
View file

@ -0,0 +1,88 @@
from typing import Callable
from django.template import Context, Template
from django_components import Component, register, registry, types
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config({"autodiscover": False})
class SlottedComponent(Component):
template_file = "slotted_template.html"
def _get_templates_used_to_render(subject_template, render_context=None):
"""Emulate django.test.client.Client (see request method)."""
from django.test.signals import template_rendered
templates_used = []
def receive_template_signal(sender, template, context, **_kwargs):
templates_used.append(template.name)
template_rendered.connect(receive_template_signal, dispatch_uid="test_method")
subject_template.render(render_context or Context({}))
template_rendered.disconnect(dispatch_uid="test_method")
return templates_used
class TemplateSignalTest(BaseTestCase):
saved_render_method: Callable # Assigned during setup.
def tearDown(self):
super().tearDown()
Template._render = self.saved_render_method
def setUp(self):
"""Emulate Django test instrumentation for TestCase (see setup_test_environment)"""
super().setUp()
from django.test.utils import instrumented_test_render
self.saved_render_method = Template._render
Template._render = instrumented_test_render
registry.clear()
registry.register("test_component", SlottedComponent)
@register("inner_component")
class SimpleComponent(Component):
template_file = "simple_template.html"
def get_context_data(self, variable, variable2="default"):
return {
"variable": variable,
"variable2": variable2,
}
class Media:
css = "style.css"
js = "script.js"
@parametrize_context_behavior(["django", "isolated"])
def test_template_rendered(self):
template_str: types.django_html = """
{% load component_tags %}
{% component 'test_component' %}{% endcomponent %}
"""
template = Template(template_str, name="root")
templates_used = _get_templates_used_to_render(template)
self.assertIn("slotted_template.html", templates_used)
@parametrize_context_behavior(["django", "isolated"])
def test_template_rendered_nested_components(self):
template_str: types.django_html = """
{% load component_tags %}
{% component 'test_component' %}
{% fill "header" %}
{% component 'inner_component' variable='foo' %}{% endcomponent %}
{% endfill %}
{% endcomponent %}
"""
template = Template(template_str, name="root")
templates_used = _get_templates_used_to_render(template)
self.assertIn("slotted_template.html", templates_used)
self.assertIn("simple_template.html", templates_used)

View file

@ -1,7 +1,5 @@
"""Catch-all for tests that use template tags and don't fit other files""" """Catch-all for tests that use template tags and don't fit other files"""
from typing import Callable
from django.template import Context, Template from django.template import Context, Template
from django_components import Component, register, registry, types from django_components import Component, register, registry, types
@ -21,79 +19,6 @@ class SlottedComponent(Component):
####################### #######################
class TemplateInstrumentationTest(BaseTestCase):
saved_render_method: Callable # Assigned during setup.
def tearDown(self):
super().tearDown()
Template._render = self.saved_render_method
def setUp(self):
"""Emulate Django test instrumentation for TestCase (see setup_test_environment)"""
super().setUp()
from django.test.utils import instrumented_test_render
self.saved_render_method = Template._render
Template._render = instrumented_test_render
registry.clear()
registry.register("test_component", SlottedComponent)
@register("inner_component")
class SimpleComponent(Component):
template_file = "simple_template.html"
def get_context_data(self, variable, variable2="default"):
return {
"variable": variable,
"variable2": variable2,
}
class Media:
css = "style.css"
js = "script.js"
def templates_used_to_render(self, subject_template, render_context=None):
"""Emulate django.test.client.Client (see request method)."""
from django.test.signals import template_rendered
templates_used = []
def receive_template_signal(sender, template, context, **_kwargs):
templates_used.append(template.name)
template_rendered.connect(receive_template_signal, dispatch_uid="test_method")
subject_template.render(render_context or Context({}))
template_rendered.disconnect(dispatch_uid="test_method")
return templates_used
@parametrize_context_behavior(["django", "isolated"])
def test_template_shown_as_used(self):
template_str: types.django_html = """
{% load component_tags %}
{% component 'test_component' %}{% endcomponent %}
"""
template = Template(template_str, name="root")
templates_used = self.templates_used_to_render(template)
self.assertIn("slotted_template.html", templates_used)
@parametrize_context_behavior(["django", "isolated"])
def test_nested_component_templates_all_shown_as_used(self):
template_str: types.django_html = """
{% load component_tags %}
{% component 'test_component' %}
{% fill "header" %}
{% component 'inner_component' variable='foo' %}{% endcomponent %}
{% endfill %}
{% endcomponent %}
"""
template = Template(template_str, name="root")
templates_used = self.templates_used_to_render(template)
self.assertIn("slotted_template.html", templates_used)
self.assertIn("simple_template.html", templates_used)
class MultilineTagsTests(BaseTestCase): class MultilineTagsTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_multiline_tags(self): def test_multiline_tags(self):

View file

@ -581,8 +581,16 @@ class MultiComponentTests(BaseTestCase):
""", """,
) )
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(
def test_both_components_render_correctly_when_only_first_has_slots(self): # TODO: Why is this the only place where this needs to be parametrized?
cases=[
("django", "data-djc-id-a1bc48"),
("isolated", "data-djc-id-a1bc45"),
]
)
def test_both_components_render_correctly_when_only_first_has_slots(self, context_behavior_data):
second_id = context_behavior_data
registry.register("first_component", SlottedComponent) registry.register("first_component", SlottedComponent)
registry.register("second_component", SlottedComponentWithContext) registry.register("second_component", SlottedComponentWithContext)
@ -599,7 +607,7 @@ class MultiComponentTests(BaseTestCase):
self.assertHTMLEqual( self.assertHTMLEqual(
rendered, rendered,
""" f"""
<custom-template data-djc-id-a1bc41> <custom-template data-djc-id-a1bc41>
<header> <header>
<p>Slot #1</p> <p>Slot #1</p>
@ -607,7 +615,7 @@ class MultiComponentTests(BaseTestCase):
<main>Default main</main> <main>Default main</main>
<footer>Default footer</footer> <footer>Default footer</footer>
</custom-template> </custom-template>
<custom-template data-djc-id-a1bc45> <custom-template {second_id}>
<header> <header>
Default header Default header
</header> </header>

View file

@ -638,7 +638,7 @@ class ExtendsCompatTests(BaseTestCase):
<!DOCTYPE html> <!DOCTYPE html>
<html data-djc-id-a1bc40 lang="en"> <html data-djc-id-a1bc40 lang="en">
<body> <body>
<custom-template data-djc-id-a1bc45> <custom-template data-djc-id-a1bc49>
<header></header> <header></header>
<main>BODY_FROM_FILL</main> <main>BODY_FROM_FILL</main>
<footer>Default footer</footer> <footer>Default footer</footer>