mirror of
https://github.com/django-components/django-components.git
synced 2025-08-17 20:50:14 +00:00
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:
parent
8e2428ebd0
commit
1e4b556b4d
12 changed files with 399 additions and 186 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
@ -8,8 +8,10 @@
|
|||
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
|
||||
access the content under `Component.js/css` - behind the scenes, the content of the JS/CSS files
|
||||
will be set to `Component.js/css` upon first access.
|
||||
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` / `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:
|
||||
|
||||
|
@ -32,7 +34,7 @@
|
|||
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()`
|
||||
|
||||
- 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:
|
||||
|
||||
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).
|
||||
|
||||
- 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
|
||||
|
||||
#### Fix
|
||||
|
|
|
@ -339,7 +339,7 @@ these file paths will be resolved only once you either:
|
|||
|
||||
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_file`](../../reference/api.md#django_components.Component.template_file),
|
||||
[`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.
|
||||
|
||||
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
|
||||
|
||||
|
|
|
@ -551,6 +551,22 @@ def gen_reference_templatevars():
|
|||
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=""):
|
||||
"""Recursively extract all URLs and their associated views from Django's urlpatterns"""
|
||||
urls: List[str] = []
|
||||
|
@ -732,6 +748,7 @@ def gen_reference():
|
|||
gen_reference_commands()
|
||||
gen_reference_templatetags()
|
||||
gen_reference_templatevars()
|
||||
gen_reference_signals()
|
||||
|
||||
|
||||
# This is run when `gen-files` plugin is run in mkdocs.yml
|
||||
|
|
37
docs/templates/reference_signals.md
vendored
Normal file
37
docs/templates/reference_signals.md
vendored
Normal 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()
|
||||
```
|
|
@ -31,6 +31,7 @@ from django.template.base import NodeList, Template, TextNode
|
|||
from django.template.context import Context, RequestContext
|
||||
from django.template.loader import get_template
|
||||
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.views import View
|
||||
|
||||
|
@ -227,8 +228,15 @@ class 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,
|
||||
or one of the roots of `STATIFILES_DIRS`.
|
||||
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 template directories, as set by Django's `TEMPLATES` setting (e.g. `<root>/templates/`).
|
||||
|
||||
Only one of [`template_file`](../api#django_components.Component.template_file),
|
||||
[`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.
|
||||
|
||||
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:
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
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
|
||||
|
||||
# NOTE: When the template is taken from a file (AKA specified via `template_file`),
|
||||
# then we leverage Django's template caching. This means that the same instance
|
||||
# of Template is reused. This is important to keep in mind, because the implication
|
||||
# is that we should treat Templates AND their nodelists as IMMUTABLE.
|
||||
# NOTE: We cache the Template instance. When the template is taken from a file
|
||||
# via `get_template_name`, then we leverage Django's template caching with `get_template()`.
|
||||
# Otherwise, we use our own `cached_template()` to cache the template.
|
||||
#
|
||||
# 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:
|
||||
# Resolve template name
|
||||
template_file = self.template_file
|
||||
if self.template_file is not None:
|
||||
if self.get_template_name(context) is not None:
|
||||
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)
|
||||
template_name = self.get_template_name(context)
|
||||
# TODO_REMOVE_IN_V1 - Remove `self.get_template_string` in v1
|
||||
template_getter = getattr(self, "get_template_string", self.get_template)
|
||||
template_body = template_getter(context)
|
||||
|
||||
# Resolve template str
|
||||
template_input = self.template
|
||||
if self.template is not None:
|
||||
if self.get_template(context) is not None:
|
||||
raise ImproperlyConfigured(
|
||||
"Received non-null value from both 'template' and 'get_template' in"
|
||||
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:
|
||||
# `get_template_name()`, `get_template()`, and `template` are mutually exclusive
|
||||
#
|
||||
# Note that `template` and `template_name` are also mutually exclusive, but this
|
||||
# is checked when lazy-loading the template from `template_name`. So if user specified
|
||||
# `template_name`, then `template` will be populated with the content of that file.
|
||||
if self.template is not None and template_name is not None:
|
||||
raise ImproperlyConfigured(
|
||||
f"Received both 'template_file' and 'template' in Component {type(self).__name__}."
|
||||
" Only one of the two must be set."
|
||||
"Received non-null value from both 'template/template_name' and 'get_template_name' in"
|
||||
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:
|
||||
return get_template(template_file).template
|
||||
if template_name is not None:
|
||||
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
|
||||
if isinstance(template_input, str):
|
||||
template: Template = cached_template(template_input)
|
||||
if isinstance(template_body, str):
|
||||
template: Template = cached_template(
|
||||
template_string=template_body,
|
||||
name=self.template_file,
|
||||
)
|
||||
else:
|
||||
template = template_input
|
||||
template = template_body
|
||||
|
||||
return template
|
||||
|
||||
|
@ -1022,6 +1052,8 @@ class Component(
|
|||
):
|
||||
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
|
||||
html_content = template.render(context)
|
||||
|
||||
|
|
|
@ -2,11 +2,13 @@ import os
|
|||
import sys
|
||||
from dataclasses import dataclass
|
||||
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.core.exceptions import ImproperlyConfigured
|
||||
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_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.
|
||||
_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.
|
||||
# Effectively, even if the Component class defined `js_file`, at "runtime" the `js` attribute
|
||||
# If the component defined `template_file`, `js_file` or `css_file`, instead of `template`/`js`/`css`,
|
||||
# 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.
|
||||
comp_media.js = _get_static_asset(
|
||||
comp_cls, comp_media, inlined_attr="js", file_attr="js_file", comp_dirs=comp_dirs
|
||||
# So users can access `Component.js` even if they defined `Component.js_file`.
|
||||
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_cls, comp_media, inlined_attr="css", file_attr="css_file", comp_dirs=comp_dirs
|
||||
comp_media.js = _get_asset(
|
||||
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
|
||||
|
@ -656,12 +668,13 @@ def _get_dir_path_from_component_path(
|
|||
return comp_dir_path_abs, comp_dir_path_rel
|
||||
|
||||
|
||||
def _get_static_asset(
|
||||
def _get_asset(
|
||||
comp_cls: Type["Component"],
|
||||
comp_media: ComponentMedia,
|
||||
inlined_attr: str,
|
||||
file_attr: str,
|
||||
comp_dirs: List[Path],
|
||||
type: Literal["template", "static"],
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
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:
|
||||
# Check if the file is in one of the components' directories
|
||||
full_path = resolve_file(asset_file, comp_dirs)
|
||||
# If not, check if it's in the static files
|
||||
|
||||
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:
|
||||
# NOTE: The short name, e.g. `js` or `css` is used in the error message for convenience
|
||||
|
|
1
tests/test_app/components/test_app_simple_template.html
Normal file
1
tests/test_app/components/test_app_simple_template.html
Normal file
|
@ -0,0 +1 @@
|
|||
Variable: <strong>{{ variable }}</strong>
|
|
@ -20,29 +20,7 @@ setup_test_config({"autodiscover": False})
|
|||
# "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.
|
||||
class MainMediaTest(BaseTestCase):
|
||||
def test_html_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):
|
||||
def test_html_js_css_inlined(self):
|
||||
class TestComponent(Component):
|
||||
template = """
|
||||
{% load component_tags %}
|
||||
|
@ -77,22 +55,37 @@ class MainMediaTest(BaseTestCase):
|
|||
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(
|
||||
STATICFILES_DIRS=[
|
||||
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
|
||||
|
||||
class TestComponent(AppLvlCompComponent):
|
||||
template_file = None
|
||||
template = """
|
||||
{% load component_tags %}
|
||||
{% component_js_dependencies %}
|
||||
{% component_css_dependencies %}
|
||||
<div class='html-css-only'>Content</div>
|
||||
"""
|
||||
pass
|
||||
|
||||
registry.register("test", TestComponent)
|
||||
|
||||
self.assertIn(
|
||||
".html-css-only {\n color: blue;\n}",
|
||||
|
@ -103,10 +96,23 @@ class MainMediaTest(BaseTestCase):
|
|||
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(
|
||||
'<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,
|
||||
)
|
||||
self.assertInHTML(
|
||||
|
@ -118,22 +124,46 @@ class MainMediaTest(BaseTestCase):
|
|||
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(
|
||||
STATICFILES_DIRS=[
|
||||
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):
|
||||
template = """
|
||||
{% load component_tags %}
|
||||
{% component_js_dependencies %}
|
||||
{% component_css_dependencies %}
|
||||
<div class='html-css-only'>Content</div>
|
||||
"""
|
||||
template_file = "test_app_simple_template.html"
|
||||
css_file = "style.css"
|
||||
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(
|
||||
".html-css-only {\n color: blue;\n}",
|
||||
TestComponent.css,
|
||||
|
@ -143,10 +173,18 @@ class MainMediaTest(BaseTestCase):
|
|||
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(
|
||||
'<div class="html-css-only" data-djc-id-a1bc3e>Content</div>',
|
||||
self.assertIn(
|
||||
"Variable: <strong data-djc-id-a1bc41>test</strong>",
|
||||
rendered,
|
||||
)
|
||||
self.assertInHTML(
|
||||
|
@ -158,22 +196,38 @@ class MainMediaTest(BaseTestCase):
|
|||
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(
|
||||
STATICFILES_DIRS=[
|
||||
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
|
||||
|
||||
class TestComponent(AppLvlCompComponent):
|
||||
template_file = None
|
||||
template = """
|
||||
{% load component_tags %}
|
||||
{% component_js_dependencies %}
|
||||
{% component_css_dependencies %}
|
||||
<div class='html-css-only'>Content</div>
|
||||
"""
|
||||
pass
|
||||
|
||||
# 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.
|
||||
|
@ -189,15 +243,40 @@ class MainMediaTest(BaseTestCase):
|
|||
# Access the property to load the CSS
|
||||
_ = TestComponent.css
|
||||
|
||||
self.assertHTMLEqual(
|
||||
self.assertEqual(
|
||||
AppLvlCompComponent._component_media.css, # type: ignore[attr-defined]
|
||||
".html-css-only { color: blue; }",
|
||||
(".html-css-only {\n" " color: blue;\n" "}\n"),
|
||||
)
|
||||
self.assertEqual(
|
||||
AppLvlCompComponent._component_media.css_file, # type: ignore[attr-defined]
|
||||
"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):
|
||||
class VariableHTMLComponent(Component):
|
||||
def get_template(self, context):
|
||||
|
|
88
tests/test_signals.py
Normal file
88
tests/test_signals.py
Normal 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)
|
|
@ -1,7 +1,5 @@
|
|||
"""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_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):
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_multiline_tags(self):
|
||||
|
|
|
@ -581,8 +581,16 @@ class MultiComponentTests(BaseTestCase):
|
|||
""",
|
||||
)
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_both_components_render_correctly_when_only_first_has_slots(self):
|
||||
@parametrize_context_behavior(
|
||||
# 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("second_component", SlottedComponentWithContext)
|
||||
|
||||
|
@ -599,7 +607,7 @@ class MultiComponentTests(BaseTestCase):
|
|||
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
f"""
|
||||
<custom-template data-djc-id-a1bc41>
|
||||
<header>
|
||||
<p>Slot #1</p>
|
||||
|
@ -607,7 +615,7 @@ class MultiComponentTests(BaseTestCase):
|
|||
<main>Default main</main>
|
||||
<footer>Default footer</footer>
|
||||
</custom-template>
|
||||
<custom-template data-djc-id-a1bc45>
|
||||
<custom-template {second_id}>
|
||||
<header>
|
||||
Default header
|
||||
</header>
|
||||
|
|
|
@ -638,7 +638,7 @@ class ExtendsCompatTests(BaseTestCase):
|
|||
<!DOCTYPE html>
|
||||
<html data-djc-id-a1bc40 lang="en">
|
||||
<body>
|
||||
<custom-template data-djc-id-a1bc45>
|
||||
<custom-template data-djc-id-a1bc49>
|
||||
<header></header>
|
||||
<main>BODY_FROM_FILL</main>
|
||||
<footer>Default footer</footer>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue