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