diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8a57335..4c9f2496 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,26 @@
Each yield operation is independent and returns its own `(html, error)` tuple, allowing you to handle each rendering result separately.
+#### Fix
+
+- Improve formatting when an exception is raised while rendering components. Error messages with newlines should now be properly formatted.
+
+- Add missing exports for `OnComponentRenderedContext`, `OnSlotRenderedContext`, `OnTemplateCompiledContext`, `OnTemplateLoadedContext`.
+
+#### Refactor
+
+- Changes to how `get_component_url()` handles query parameters:
+ - `True` values are now converted to boolean flags (e.g. `?enabled` instead of `?enabled=True`).
+ - `False` and `None` values are now filtered out.
+
+ ```py
+ url = get_component_url(
+ MyComponent,
+ query={"abc": 123, "enabled": True, "debug": False, "none_key": None},
+ )
+ # /components/ext/view/components/c1ab2c3?abc=123&enabled
+ ```
+
## v0.141.6
#### Fix
diff --git a/docs/concepts/fundamentals/component_views_urls.md b/docs/concepts/fundamentals/component_views_urls.md
index f8398749..00719f74 100644
--- a/docs/concepts/fundamentals/component_views_urls.md
+++ b/docs/concepts/fundamentals/component_views_urls.md
@@ -10,8 +10,8 @@ For web applications, it's common to define endpoints that serve HTML content (A
django-components has a suite of features that help you write and manage views and their URLs:
- For each component, you can define methods for handling HTTP requests (GET, POST, etc.) - `get()`, `post()`, etc.
-
-- Use [`Component.as_view()`](../../../reference/api#django_components.Component.as_view) to be able to use your Components with Django's [`urlpatterns`](https://docs.djangoproject.com/en/5.2/topics/http/urls/). This works the same way as [`View.as_view()`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#django.views.generic.base.View.as_view).
+
+- Use [`Component.as_view()`](../../../reference/api#django_components.Component.as_view) to be able to use your Components with Django's [`urlpatterns`](https://docs.djangoproject.com/en/5.2/topics/http/urls/). This works the same way as [`View.as_view()`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#django.views.generic.base.View.as_view).
- To avoid having to manually define the endpoints for each component, you can set the component to be "public" with [`Component.View.public = True`](../../../reference/api#django_components.ComponentView.public). This will automatically create a URL for the component. To retrieve the component URL, use [`get_component_url()`](../../../reference/api#django_components.get_component_url).
@@ -60,7 +60,6 @@ class Calendar(Component):
Each of these receive the [`HttpRequest`](https://docs.djangoproject.com/en/5.2/ref/request-response/#django.http.HttpRequest) object as the first argument.
-
!!! warning
@@ -111,7 +110,7 @@ urlpatterns = [
[`Component.as_view()`](../../../reference/api#django_components.Component.as_view)
internally calls [`View.as_view()`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#django.views.generic.base.View.as_view), passing the component
-instance as one of the arguments.
+class as one of the arguments.
## Register URLs automatically
@@ -144,8 +143,14 @@ This way you don't have to mix your app URLs with component URLs.
```py
url = get_component_url(
MyComponent,
- query={"foo": "bar"},
+ query={"foo": "bar", "enabled": True, "debug": False, "unused": None},
fragment="baz",
)
- # /components/ext/view/components/c1ab2c3?foo=bar#baz
+ # /components/ext/view/components/c1ab2c3?foo=bar&enabled#baz
```
+
+ **Query parameter handling:**
+
+ - `True` values are rendered as flag parameters without values (e.g., `?enabled`)
+ - `False` and `None` values are omitted from the URL
+ - Other values are rendered normally (e.g., `?foo=bar`)
diff --git a/docs/reference/extension_hooks.md b/docs/reference/extension_hooks.md
index e9f95c07..ca374863 100644
--- a/docs/reference/extension_hooks.md
+++ b/docs/reference/extension_hooks.md
@@ -308,6 +308,11 @@ name | type | description
heading_level: 3
show_if_no_docstring: true
+::: django_components.extension.OnComponentRenderedContext
+ options:
+ heading_level: 3
+ show_if_no_docstring: true
+
::: django_components.extension.OnComponentUnregisteredContext
options:
heading_level: 3
@@ -323,3 +328,18 @@ name | type | description
heading_level: 3
show_if_no_docstring: true
+::: django_components.extension.OnSlotRenderedContext
+ options:
+ heading_level: 3
+ show_if_no_docstring: true
+
+::: django_components.extension.OnTemplateCompiledContext
+ options:
+ heading_level: 3
+ show_if_no_docstring: true
+
+::: django_components.extension.OnTemplateLoadedContext
+ options:
+ heading_level: 3
+ show_if_no_docstring: true
+
diff --git a/requirements-dev.in b/requirements-dev.in
index 57f1350f..bafcfd56 100644
--- a/requirements-dev.in
+++ b/requirements-dev.in
@@ -13,6 +13,7 @@ playwright
requests
types-requests
whitenoise
+pydantic
pygments
pygments-djc
asv
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 28fbc328..98ed8d75 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -4,17 +4,17 @@
#
# pip-compile requirements-dev.in
#
-asgiref==3.9.1
+annotated-types==0.7.0
+ # via pydantic
+asgiref==3.9.2
# via django
asv==0.6.5
# via -r requirements-dev.in
asv-runner==0.2.1
# via asv
-backports-asyncio-runner==1.2.0
- # via pytest-asyncio
build==1.3.0
# via asv
-cachetools==6.1.0
+cachetools==6.2.0
# via tox
certifi==2025.8.3
# via requests
@@ -28,7 +28,7 @@ colorama==0.4.6
# via tox
distlib==0.4.0
# via virtualenv
-django==4.2.24
+django==5.2.6
# via
# -r requirements-dev.in
# django-template-partials
@@ -36,15 +36,13 @@ django-template-partials==25.2
# via -r requirements-dev.in
djc-core-html-parser==1.0.2
# via -r requirements-dev.in
-exceptiongroup==1.3.0
- # via pytest
filelock==3.19.1
# via
# tox
# virtualenv
greenlet==3.2.4
# via playwright
-identify==2.6.13
+identify==2.6.14
# via pre-commit
idna==3.10
# via requests
@@ -52,7 +50,6 @@ importlib-metadata==8.7.0
# via
# asv
# asv-runner
- # build
iniconfig==2.1.0
# via pytest
json5==0.12.1
@@ -74,11 +71,11 @@ pathspec==0.12.1
# via
# -r requirements-dev.in
# mypy
-platformdirs==4.3.8
+platformdirs==4.4.0
# via
# tox
# virtualenv
-playwright==1.54.0
+playwright==1.55.0
# via -r requirements-dev.in
pluggy==1.6.0
# via
@@ -86,6 +83,10 @@ pluggy==1.6.0
# tox
pre-commit==4.3.0
# via -r requirements-dev.in
+pydantic==2.11.9
+ # via -r requirements-dev.in
+pydantic-core==2.33.2
+ # via pydantic
pyee==13.0.0
# via playwright
pygments==2.19.2
@@ -101,52 +102,45 @@ pyproject-api==1.9.1
# via tox
pyproject-hooks==1.2.0
# via build
-pytest==8.4.1
+pytest==8.4.2
# via
# -r requirements-dev.in
# pytest-asyncio
# pytest-django
# syrupy
-pytest-asyncio==1.1.0
+pytest-asyncio==1.2.0
# via -r requirements-dev.in
pytest-django==4.11.1
# via -r requirements-dev.in
-pyyaml==6.0.2
+pyyaml==6.0.3
# via
# asv
# pre-commit
-requests==2.32.4
+requests==2.32.5
# via -r requirements-dev.in
ruff==0.13.2
# via -r requirements-dev.in
sqlparse==0.5.3
# via django
-syrupy==4.9.1
+syrupy==5.0.0
# via -r requirements-dev.in
tabulate==0.9.0
# via asv
-tomli==2.2.1
- # via
- # asv
- # build
- # mypy
- # pyproject-api
- # pytest
- # tox
-tox==4.28.4
+tox==4.30.2
# via -r requirements-dev.in
-types-requests==2.32.4.20250809
+types-requests==2.32.4.20250913
# via -r requirements-dev.in
-typing-extensions==4.14.1
+typing-extensions==4.15.0
# via
# -r requirements-dev.in
- # asgiref
- # exceptiongroup
# mypy
+ # pydantic
+ # pydantic-core
# pyee
# pytest-asyncio
- # tox
- # virtualenv
+ # typing-inspection
+typing-inspection==0.4.1
+ # via pydantic
urllib3==2.5.0
# via
# requests
@@ -157,7 +151,7 @@ virtualenv==20.34.0
# asv
# pre-commit
# tox
-whitenoise==6.9.0
+whitenoise==6.11.0
# via -r requirements-dev.in
zipp==3.23.0
# via importlib-metadata
diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py
index 9dd87afd..d5b3070b 100644
--- a/src/django_components/__init__.py
+++ b/src/django_components/__init__.py
@@ -34,7 +34,6 @@ from django_components.component_registry import (
registry,
all_registries,
)
-from django_components.components import DynamicComponent
from django_components.dependencies import DependenciesStrategy, render_dependencies
from django_components.extension import (
ComponentExtension,
@@ -47,6 +46,10 @@ from django_components.extension import (
OnComponentClassDeletedContext,
OnComponentInputContext,
OnComponentDataContext,
+ OnComponentRenderedContext,
+ OnSlotRenderedContext,
+ OnTemplateCompiledContext,
+ OnTemplateLoadedContext,
)
from django_components.extensions.cache import ComponentCache
from django_components.extensions.defaults import ComponentDefaults, Default
@@ -81,6 +84,9 @@ from django_components.util.loader import ComponentFileEntry, get_component_dirs
from django_components.util.routing import URLRoute, URLRouteHandler
from django_components.util.types import Empty
+# NOTE: Import built-in components last to avoid circular imports
+from django_components.components import DynamicComponent
+
# isort: on
@@ -122,10 +128,14 @@ __all__ = [
"OnComponentDataContext",
"OnComponentInputContext",
"OnComponentRegisteredContext",
+ "OnComponentRenderedContext",
"OnComponentUnregisteredContext",
"OnRegistryCreatedContext",
"OnRegistryDeletedContext",
"OnRenderGenerator",
+ "OnSlotRenderedContext",
+ "OnTemplateCompiledContext",
+ "OnTemplateLoadedContext",
"ProvideNode",
"RegistrySettings",
"ShorthandComponentFormatter",
diff --git a/src/django_components/extensions/view.py b/src/django_components/extensions/view.py
index c23496b4..7a3f89a1 100644
--- a/src/django_components/extensions/view.py
+++ b/src/django_components/extensions/view.py
@@ -48,6 +48,12 @@ def get_component_url(
`get_component_url()` optionally accepts `query` and `fragment` arguments.
+ **Query parameter handling:**
+
+ - `True` values are rendered as flag parameters without values (e.g., `?enabled`)
+ - `False` and `None` values are omitted from the URL
+ - Other values are rendered normally (e.g., `?foo=bar`)
+
**Example:**
```py
@@ -60,10 +66,10 @@ def get_component_url(
# Get the URL for the component
url = get_component_url(
MyComponent,
- query={"foo": "bar"},
+ query={"foo": "bar", "enabled": True, "debug": False, "unused": None},
fragment="baz",
)
- # /components/ext/view/components/c1ab2c3?foo=bar#baz
+ # /components/ext/view/components/c1ab2c3?foo=bar&enabled#baz
```
"""
view_cls: Optional[Type[ComponentView]] = getattr(component, "View", None)
diff --git a/src/django_components/util/exception.py b/src/django_components/util/exception.py
index dc57b7c3..f7b6fd78 100644
--- a/src/django_components/util/exception.py
+++ b/src/django_components/util/exception.py
@@ -20,20 +20,27 @@ def component_error_message(component_path: List[str]) -> Generator[None, None,
components = getattr(err, "_components", [])
components = err._components = [*component_path, *components] # type: ignore[attr-defined]
- # Access the exception's message, see https://stackoverflow.com/a/75549200/9788634
- if len(err.args) and err.args[0] is not None:
- if not components:
- orig_msg = str(err.args[0])
- else:
- orig_msg = str(err.args[0]).split("\n", 1)[-1]
- else:
- orig_msg = str(err)
-
# Format component path as
# "MyPage > MyComponent > MyComponent(slot:content) > Base(slot:tab)"
comp_path = " > ".join(components)
prefix = f"An error occured while rendering components {comp_path}:\n"
+ # Access the exception's message, see https://stackoverflow.com/a/75549200/9788634
+ if len(err.args) and err.args[0] is not None:
+ orig_msg = str(err.args[0])
+ if components and "An error occured while rendering components" in orig_msg:
+ orig_msg = str(err.args[0]).split("\n", 1)[-1]
+ else:
+ # When the exception has no message, it may be that the exception
+ # does NOT rely on the `args` attribute. Such case is for example
+ # Pydantic exceptions.
+ #
+ # In this case, we still try to use the `args` attribute, but
+ # it's not guaranteed to work. So we also print out the component
+ # path ourselves.
+ print(prefix) # noqa: T201
+ orig_msg = str(err)
+
err.args = (prefix + orig_msg,) # tuple of one
# `from None` should still raise the original error, but without showing this
diff --git a/src/django_components/util/misc.py b/src/django_components/util/misc.py
index 8aa456e6..94f920eb 100644
--- a/src/django_components/util/misc.py
+++ b/src/django_components/util/misc.py
@@ -162,12 +162,35 @@ def format_url(url: str, query: Optional[Dict] = None, fragment: Optional[str] =
```
`query` and `fragment` are optional, and not applied if `None`.
+
+ Boolean `True` values in query parameters are rendered as flag parameters without values.
+
+ `False` and `None` values in query parameters are omitted.
+
+ ```py
+ url = format_url(
+ url="https://example.com",
+ query={"foo": "bar", "baz": None, "enabled": True, "debug": False},
+ )
+ # https://example.com?foo=bar&enabled
+ ```
"""
parts = parse.urlsplit(url)
fragment_enc = parse.quote(fragment or parts.fragment, safe="")
base_qs = dict(parse.parse_qsl(parts.query))
- merged = {**base_qs, **(query or {})}
- encoded_qs = parse.urlencode(merged, safe="")
+ # Filter out `None` and `False` values
+ filtered_query = {k: v for k, v in (query or {}).items() if v is not None and v is not False}
+ merged = {**base_qs, **filtered_query}
+
+ # Handle boolean True values as flag parameters (no explicit value)
+ query_parts = []
+ for key, value in merged.items():
+ if value is True:
+ query_parts.append(parse.quote_plus(str(key)))
+ else:
+ query_parts.append(f"{parse.quote_plus(str(key))}={parse.quote_plus(str(value))}")
+
+ encoded_qs = "&".join(query_parts)
return parse.urlunsplit(parts._replace(query=encoded_qs, fragment=fragment_enc))
diff --git a/tests/test_component.py b/tests/test_component.py
index baeaea5c..12df4268 100644
--- a/tests/test_component.py
+++ b/tests/test_component.py
@@ -1471,6 +1471,56 @@ class TestComponentRender:
):
Root.render()
+ @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
+ def test_pydantic_exception(self, components_settings):
+ from pydantic import BaseModel, ValidationError
+
+ @register("broken")
+ class Broken(Component):
+ template: types.django_html = """
+ {% load component_tags %}
+
injected: {{ data|safe }}
+
+ {% slot "content" default / %}
+
+ """
+
+ class Kwargs(BaseModel):
+ data1: str
+
+ def get_template_data(self, args, kwargs: Kwargs, slots, context):
+ return {"data": kwargs.data1}
+
+ @register("parent")
+ class Parent(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ return {"data": kwargs["data"]}
+
+ template: types.django_html = """
+ {% load component_tags %}
+ {% component "broken" %}
+ {% slot "content" default / %}
+ {% endcomponent %}
+ """
+
+ @register("root")
+ class Root(Component):
+ template: types.django_html = """
+ {% load component_tags %}
+ {% component "parent" data=123 %}
+ {% fill "content" %}
+ 456
+ {% endfill %}
+ {% endcomponent %}
+ """
+
+ # NOTE: We're unable to insert the component path in the Pydantic's exception message
+ with pytest.raises(
+ ValidationError,
+ match=re.escape("1 validation error for Kwargs\ndata1\n Field required"),
+ ):
+ Root.render()
+
@djc_test
class TestComponentHook:
diff --git a/tests/test_component_dynamic.py b/tests/test_component_dynamic.py
new file mode 100644
index 00000000..f2e68892
--- /dev/null
+++ b/tests/test_component_dynamic.py
@@ -0,0 +1,349 @@
+import re
+from typing import NamedTuple
+
+import pytest
+from django.template import Context, Template
+from pytest_django.asserts import assertHTMLEqual
+
+from django_components import AlreadyRegistered, Component, DynamicComponent, NotRegistered, registry, types
+from django_components.testing import djc_test
+
+from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
+
+setup_test_config({"autodiscover": False})
+
+
+@djc_test
+class TestDynamicComponent:
+ def _gen_simple_component(self):
+ class SimpleComponent(Component):
+ template: types.django_html = """
+ Variable: {{ variable }}
+ """
+
+ class Kwargs(NamedTuple):
+ variable: str
+ variable2: str
+
+ class Defaults:
+ variable2 = "default"
+
+ def get_template_data(self, args, kwargs: Kwargs, slots, context):
+ return {
+ "variable": kwargs.variable,
+ "variable2": kwargs.variable2,
+ }
+
+ class Media:
+ css = "style.css"
+ js = "script.js"
+
+ return SimpleComponent
+
+ @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
+ def test_basic__python(self, components_settings):
+ registry.register(name="test", component=self._gen_simple_component())
+
+ rendered = DynamicComponent.render(
+ kwargs={
+ "is": "test",
+ "variable": "variable",
+ },
+ )
+ assertHTMLEqual(
+ rendered,
+ "Variable: variable",
+ )
+
+ @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
+ def test_basic__template(self, components_settings):
+ registry.register(name="test", component=self._gen_simple_component())
+
+ simple_tag_template: types.django_html = """
+ {% load component_tags %}
+ {% component "dynamic" is="test" variable="variable" %}{% endcomponent %}
+ """
+
+ template = Template(simple_tag_template)
+ rendered = template.render(Context({}))
+ assertHTMLEqual(
+ rendered,
+ "Variable: variable",
+ )
+
+ @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
+ def test_call_with_invalid_name(self, components_settings):
+ registry.register(name="test", component=self._gen_simple_component())
+
+ simple_tag_template: types.django_html = """
+ {% load component_tags %}
+ {% component "dynamic" is="haber_der_baber" variable="variable" %}{% endcomponent %}
+ """
+
+ template = Template(simple_tag_template)
+ with pytest.raises(NotRegistered, match=re.escape("The component 'haber_der_baber' was not found")):
+ template.render(Context({}))
+
+ @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
+ def test_component_called_with_variable_as_name(self, components_settings):
+ registry.register(name="test", component=self._gen_simple_component())
+
+ simple_tag_template: types.django_html = """
+ {% load component_tags %}
+ {% with component_name="test" %}
+ {% component "dynamic" is=component_name variable="variable" %}{% endcomponent %}
+ {% endwith %}
+ """
+
+ template = Template(simple_tag_template)
+ rendered = template.render(Context({}))
+ assertHTMLEqual(
+ rendered,
+ "Variable: variable",
+ )
+
+ @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
+ def test_component_called_with_variable_as_spread(self, components_settings):
+ registry.register(name="test", component=self._gen_simple_component())
+
+ simple_tag_template: types.django_html = """
+ {% load component_tags %}
+ {% component "dynamic" ...props %}{% endcomponent %}
+ """
+
+ template = Template(simple_tag_template)
+ rendered = template.render(
+ Context(
+ {
+ "props": {
+ "is": "test",
+ "variable": "variable",
+ },
+ },
+ ),
+ )
+ assertHTMLEqual(
+ rendered,
+ "Variable: variable",
+ )
+
+ @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
+ def test_component_as_class(self, components_settings):
+ SimpleComponent = self._gen_simple_component()
+ registry.register(name="test", component=SimpleComponent)
+
+ simple_tag_template: types.django_html = """
+ {% load component_tags %}
+ {% component "dynamic" is=comp_cls variable="variable" %}{% endcomponent %}
+ """
+
+ template = Template(simple_tag_template)
+ rendered = template.render(
+ Context(
+ {
+ "comp_cls": SimpleComponent,
+ },
+ ),
+ )
+ assertHTMLEqual(
+ rendered,
+ "Variable: variable",
+ )
+
+ @djc_test(
+ parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR,
+ components_settings={
+ "tag_formatter": "django_components.component_shorthand_formatter",
+ "autodiscover": False,
+ },
+ )
+ def test_shorthand_formatter(self, components_settings):
+ from django_components.apps import ComponentsConfig
+
+ ComponentsConfig.ready(None) # type: ignore[arg-type]
+
+ registry.register(name="test", component=self._gen_simple_component())
+
+ simple_tag_template: types.django_html = """
+ {% load component_tags %}
+ {% dynamic is="test" variable="variable" %}{% enddynamic %}
+ """
+
+ template = Template(simple_tag_template)
+ rendered = template.render(Context({}))
+ assertHTMLEqual(rendered, "Variable: variable\n")
+
+ @djc_test(
+ parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR,
+ components_settings={
+ "dynamic_component_name": "uno_reverse",
+ "tag_formatter": "django_components.component_shorthand_formatter",
+ "autodiscover": False,
+ },
+ )
+ def test_component_name_is_configurable(self, components_settings):
+ from django_components.apps import ComponentsConfig
+
+ ComponentsConfig.ready(None) # type: ignore[arg-type]
+
+ registry.register(name="test", component=self._gen_simple_component())
+
+ simple_tag_template: types.django_html = """
+ {% load component_tags %}
+ {% uno_reverse is="test" variable="variable" %}{% enduno_reverse %}
+ """
+
+ template = Template(simple_tag_template)
+ rendered = template.render(Context({}))
+ assertHTMLEqual(
+ rendered,
+ "Variable: variable",
+ )
+
+ @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
+ def test_raises_already_registered_on_name_conflict(self, components_settings):
+ with pytest.raises(
+ AlreadyRegistered,
+ match=re.escape('The component "dynamic" has already been registered'),
+ ):
+ registry.register(name="dynamic", component=self._gen_simple_component())
+
+ @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
+ def test_component_called_with_default_slot(self, components_settings):
+ class SimpleSlottedComponent(Component):
+ template: types.django_html = """
+ {% load component_tags %}
+ Variable: {{ variable }}
+ Slot: {% slot "default" default / %}
+ """
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "variable": kwargs["variable"],
+ "variable2": kwargs.get("variable2", "default"),
+ }
+
+ registry.register(name="test", component=SimpleSlottedComponent)
+
+ simple_tag_template: types.django_html = """
+ {% load component_tags %}
+ {% with component_name="test" %}
+ {% component "dynamic" is=component_name variable="variable" %}
+ HELLO_FROM_SLOT
+ {% endcomponent %}
+ {% endwith %}
+ """
+
+ template = Template(simple_tag_template)
+ rendered = template.render(Context({}))
+ assertHTMLEqual(
+ rendered,
+ """
+ Variable: variable
+ Slot: HELLO_FROM_SLOT
+ """,
+ )
+
+ @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
+ def test_component_called_with_named_slots(self, components_settings):
+ class SimpleSlottedComponent(Component):
+ template: types.django_html = """
+ {% load component_tags %}
+ Variable: {{ variable }}
+ Slot 1: {% slot "default" default / %}
+ Slot 2: {% slot "two" / %}
+ """
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "variable": kwargs["variable"],
+ "variable2": kwargs.get("variable2", "default"),
+ }
+
+ registry.register(name="test", component=SimpleSlottedComponent)
+
+ simple_tag_template: types.django_html = """
+ {% load component_tags %}
+ {% with component_name="test" %}
+ {% component "dynamic" is=component_name variable="variable" %}
+ {% fill "default" %}
+ HELLO_FROM_SLOT_1
+ {% endfill %}
+ {% fill "two" %}
+ HELLO_FROM_SLOT_2
+ {% endfill %}
+ {% endcomponent %}
+ {% endwith %}
+ """
+
+ template = Template(simple_tag_template)
+ rendered = template.render(Context({}))
+ assertHTMLEqual(
+ rendered,
+ """
+ Variable: variable
+ Slot 1: HELLO_FROM_SLOT_1
+ Slot 2: HELLO_FROM_SLOT_2
+ """,
+ )
+
+ @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
+ def test_ignores_invalid_slots(self, components_settings):
+ class SimpleSlottedComponent(Component):
+ template: types.django_html = """
+ {% load component_tags %}
+ Variable: {{ variable }}
+ Slot 1: {% slot "default" default / %}
+ Slot 2: {% slot "two" / %}
+ """
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "variable": kwargs["variable"],
+ "variable2": kwargs.get("variable2", "default"),
+ }
+
+ registry.register(name="test", component=SimpleSlottedComponent)
+
+ simple_tag_template: types.django_html = """
+ {% load component_tags %}
+ {% with component_name="test" %}
+ {% component "dynamic" is=component_name variable="variable" %}
+ {% fill "default" %}
+ HELLO_FROM_SLOT_1
+ {% endfill %}
+ {% fill "three" %}
+ HELLO_FROM_SLOT_2
+ {% endfill %}
+ {% endcomponent %}
+ {% endwith %}
+ """
+
+ template = Template(simple_tag_template)
+ rendered = template.render(Context({}))
+ assertHTMLEqual(
+ rendered,
+ """
+ Variable: variable
+ Slot 1: HELLO_FROM_SLOT_1
+ Slot 2:
+ """,
+ )
+
+ @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
+ def test_raises_on_invalid_input(self, components_settings):
+ registry.register(name="test", component=self._gen_simple_component())
+
+ simple_tag_template: types.django_html = """
+ {% load component_tags %}
+ {% with component_name="test" %}
+ {% component "dynamic" is=component_name invalid_variable="variable" %}{% endcomponent %}
+ {% endwith %}
+ """
+
+ template = Template(simple_tag_template)
+ with pytest.raises(
+ TypeError,
+ match=re.escape("got an unexpected keyword argument 'invalid_variable'"),
+ ):
+ template.render(Context({}))
diff --git a/tests/test_component_view.py b/tests/test_component_view.py
index d35e866a..929b6782 100644
--- a/tests/test_component_view.py
+++ b/tests/test_component_view.py
@@ -307,6 +307,18 @@ class TestComponentAsView:
== f"/components/ext/view/components/{TestComponent.class_id}/?f%27oo=b+ar%26ba%27z#q%20u%22x"
)
+ # Converts `True` to no flag parameter (no value, e.g. `enabled` instead of `enabled=True`)
+ # And filters out `False` and `None` values
+ component_url3 = get_component_url(
+ TestComponent,
+ query={"f'oo": "b ar&ba'z", "true_key": True, "false_key": False, "none_key": None},
+ fragment='q u"x',
+ )
+ assert (
+ component_url3
+ == f"/components/ext/view/components/{TestComponent.class_id}/?f%27oo=b+ar%26ba%27z&true_key#q%20u%22x"
+ )
+
# Merges query params from original URL
component_url4 = format_url(
"/components/ext/view/components/123?foo=123&bar=456#abc",
diff --git a/tests/test_templatetags_component.py b/tests/test_templatetags_component.py
index 026d9056..2b54fc1e 100644
--- a/tests/test_templatetags_component.py
+++ b/tests/test_templatetags_component.py
@@ -1,11 +1,10 @@
import re
-from typing import NamedTuple
import pytest
from django.template import Context, Template, TemplateSyntaxError
from pytest_django.asserts import assertHTMLEqual
-from django_components import AlreadyRegistered, Component, NotRegistered, register, registry, types
+from django_components import Component, NotRegistered, register, registry, types
from django_components.testing import djc_test
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
@@ -208,323 +207,6 @@ class TestComponentTemplateTag:
)
-@djc_test
-class TestDynamicComponentTemplateTag:
- class SimpleComponent(Component):
- template: types.django_html = """
- Variable: {{ variable }}
- """
-
- class Kwargs(NamedTuple):
- variable: str
- variable2: str
-
- class Defaults:
- variable2 = "default"
-
- def get_template_data(self, args, kwargs: Kwargs, slots, context):
- return {
- "variable": kwargs.variable,
- "variable2": kwargs.variable2,
- }
-
- class Media:
- css = "style.css"
- js = "script.js"
-
- @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
- def test_basic(self, components_settings):
- registry.register(name="test", component=self.SimpleComponent)
-
- simple_tag_template: types.django_html = """
- {% load component_tags %}
- {% component "dynamic" is="test" variable="variable" %}{% endcomponent %}
- """
-
- template = Template(simple_tag_template)
- rendered = template.render(Context({}))
- assertHTMLEqual(
- rendered,
- "Variable: variable",
- )
-
- @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
- def test_call_with_invalid_name(self, components_settings):
- registry.register(name="test", component=self.SimpleComponent)
-
- simple_tag_template: types.django_html = """
- {% load component_tags %}
- {% component "dynamic" is="haber_der_baber" variable="variable" %}{% endcomponent %}
- """
-
- template = Template(simple_tag_template)
- with pytest.raises(NotRegistered, match=re.escape("The component 'haber_der_baber' was not found")):
- template.render(Context({}))
-
- @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
- def test_component_called_with_variable_as_name(self, components_settings):
- registry.register(name="test", component=self.SimpleComponent)
-
- simple_tag_template: types.django_html = """
- {% load component_tags %}
- {% with component_name="test" %}
- {% component "dynamic" is=component_name variable="variable" %}{% endcomponent %}
- {% endwith %}
- """
-
- template = Template(simple_tag_template)
- rendered = template.render(Context({}))
- assertHTMLEqual(
- rendered,
- "Variable: variable",
- )
-
- @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
- def test_component_called_with_variable_as_spread(self, components_settings):
- registry.register(name="test", component=self.SimpleComponent)
-
- simple_tag_template: types.django_html = """
- {% load component_tags %}
- {% component "dynamic" ...props %}{% endcomponent %}
- """
-
- template = Template(simple_tag_template)
- rendered = template.render(
- Context(
- {
- "props": {
- "is": "test",
- "variable": "variable",
- },
- }
- )
- )
- assertHTMLEqual(
- rendered,
- "Variable: variable",
- )
-
- @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
- def test_component_as_class(self, components_settings):
- registry.register(name="test", component=self.SimpleComponent)
-
- simple_tag_template: types.django_html = """
- {% load component_tags %}
- {% component "dynamic" is=comp_cls variable="variable" %}{% endcomponent %}
- """
-
- template = Template(simple_tag_template)
- rendered = template.render(
- Context(
- {
- "comp_cls": self.SimpleComponent,
- }
- )
- )
- assertHTMLEqual(
- rendered,
- "Variable: variable",
- )
-
- @djc_test(
- parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR,
- components_settings={
- "tag_formatter": "django_components.component_shorthand_formatter",
- "autodiscover": False,
- },
- )
- def test_shorthand_formatter(self, components_settings):
- from django_components.apps import ComponentsConfig
-
- ComponentsConfig.ready(None) # type: ignore[arg-type]
-
- registry.register(name="test", component=self.SimpleComponent)
-
- simple_tag_template: types.django_html = """
- {% load component_tags %}
- {% dynamic is="test" variable="variable" %}{% enddynamic %}
- """
-
- template = Template(simple_tag_template)
- rendered = template.render(Context({}))
- assertHTMLEqual(rendered, "Variable: variable\n")
-
- @djc_test(
- parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR,
- components_settings={
- "dynamic_component_name": "uno_reverse",
- "tag_formatter": "django_components.component_shorthand_formatter",
- "autodiscover": False,
- },
- )
- def test_component_name_is_configurable(self, components_settings):
- from django_components.apps import ComponentsConfig
-
- ComponentsConfig.ready(None) # type: ignore[arg-type]
-
- registry.register(name="test", component=self.SimpleComponent)
-
- simple_tag_template: types.django_html = """
- {% load component_tags %}
- {% uno_reverse is="test" variable="variable" %}{% enduno_reverse %}
- """
-
- template = Template(simple_tag_template)
- rendered = template.render(Context({}))
- assertHTMLEqual(
- rendered,
- "Variable: variable",
- )
-
- @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
- def test_raises_already_registered_on_name_conflict(self, components_settings):
- with pytest.raises(
- AlreadyRegistered,
- match=re.escape('The component "dynamic" has already been registered'),
- ):
- registry.register(name="dynamic", component=self.SimpleComponent)
-
- @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
- def test_component_called_with_default_slot(self, components_settings):
- class SimpleSlottedComponent(Component):
- template: types.django_html = """
- {% load component_tags %}
- Variable: {{ variable }}
- Slot: {% slot "default" default / %}
- """
-
- def get_template_data(self, args, kwargs, slots, context):
- return {
- "variable": kwargs["variable"],
- "variable2": kwargs.get("variable2", "default"),
- }
-
- registry.register(name="test", component=SimpleSlottedComponent)
-
- simple_tag_template: types.django_html = """
- {% load component_tags %}
- {% with component_name="test" %}
- {% component "dynamic" is=component_name variable="variable" %}
- HELLO_FROM_SLOT
- {% endcomponent %}
- {% endwith %}
- """
-
- template = Template(simple_tag_template)
- rendered = template.render(Context({}))
- assertHTMLEqual(
- rendered,
- """
- Variable: variable
- Slot: HELLO_FROM_SLOT
- """,
- )
-
- @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
- def test_component_called_with_named_slots(self, components_settings):
- class SimpleSlottedComponent(Component):
- template: types.django_html = """
- {% load component_tags %}
- Variable: {{ variable }}
- Slot 1: {% slot "default" default / %}
- Slot 2: {% slot "two" / %}
- """
-
- def get_template_data(self, args, kwargs, slots, context):
- return {
- "variable": kwargs["variable"],
- "variable2": kwargs.get("variable2", "default"),
- }
-
- registry.register(name="test", component=SimpleSlottedComponent)
-
- simple_tag_template: types.django_html = """
- {% load component_tags %}
- {% with component_name="test" %}
- {% component "dynamic" is=component_name variable="variable" %}
- {% fill "default" %}
- HELLO_FROM_SLOT_1
- {% endfill %}
- {% fill "two" %}
- HELLO_FROM_SLOT_2
- {% endfill %}
- {% endcomponent %}
- {% endwith %}
- """
-
- template = Template(simple_tag_template)
- rendered = template.render(Context({}))
- assertHTMLEqual(
- rendered,
- """
- Variable: variable
- Slot 1: HELLO_FROM_SLOT_1
- Slot 2: HELLO_FROM_SLOT_2
- """,
- )
-
- @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
- def test_ignores_invalid_slots(self, components_settings):
- class SimpleSlottedComponent(Component):
- template: types.django_html = """
- {% load component_tags %}
- Variable: {{ variable }}
- Slot 1: {% slot "default" default / %}
- Slot 2: {% slot "two" / %}
- """
-
- def get_template_data(self, args, kwargs, slots, context):
- return {
- "variable": kwargs["variable"],
- "variable2": kwargs.get("variable2", "default"),
- }
-
- registry.register(name="test", component=SimpleSlottedComponent)
-
- simple_tag_template: types.django_html = """
- {% load component_tags %}
- {% with component_name="test" %}
- {% component "dynamic" is=component_name variable="variable" %}
- {% fill "default" %}
- HELLO_FROM_SLOT_1
- {% endfill %}
- {% fill "three" %}
- HELLO_FROM_SLOT_2
- {% endfill %}
- {% endcomponent %}
- {% endwith %}
- """
-
- template = Template(simple_tag_template)
- rendered = template.render(Context({}))
- assertHTMLEqual(
- rendered,
- """
- Variable: variable
- Slot 1: HELLO_FROM_SLOT_1
- Slot 2:
- """,
- )
-
- @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
- def test_raises_on_invalid_input(self, components_settings):
- registry.register(name="test", component=self.SimpleComponent)
-
- simple_tag_template: types.django_html = """
- {% load component_tags %}
- {% with component_name="test" %}
- {% component "dynamic" is=component_name invalid_variable="variable" %}{% endcomponent %}
- {% endwith %}
- """
-
- template = Template(simple_tag_template)
- with pytest.raises(
- TypeError,
- match=re.escape("got an unexpected keyword argument 'invalid_variable'"),
- ):
- template.render(Context({}))
-
-
@djc_test
class TestMultiComponent:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
diff --git a/tox.ini b/tox.ini
index 237fbf4c..07cd812e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -39,6 +39,7 @@ deps =
# Othrwise we get error:
# playwright._impl._errors.Error: BrowserType.launch: Executable doesn't exist at /home/runner/.cache/ms-playwright/chromium-1140/chrome-linux/chrome
playwright==1.48.0
+ pydantic
requests
types-requests
whitenoise
@@ -60,6 +61,7 @@ deps =
syrupy # snapshot testing
# NOTE: Keep playwright in sync with the version in requirements-ci.txt
playwright==1.48.0
+ pydantic
requests
types-requests
whitenoise