diff --git a/CHANGELOG.md b/CHANGELOG.md index aae6950f..84419519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -275,6 +275,19 @@ - Ignores placeholders and any `` / `` tags. - No extra script loaded. +- `get_component_url()` now optionally accepts `query` and `fragment` arguments. + + ```py + from django_components import get_component_url + + url = get_component_url( + MyComponent, + query={"foo": "bar"}, + fragment="baz", + ) + # /components/ext/view/components/c1ab2c3?foo=bar#baz + ``` + ## v0.139.1 #### Fix diff --git a/docs/concepts/fundamentals/component_views_urls.md b/docs/concepts/fundamentals/component_views_urls.md index c97c96da..5166fda5 100644 --- a/docs/concepts/fundamentals/component_views_urls.md +++ b/docs/concepts/fundamentals/component_views_urls.md @@ -147,3 +147,16 @@ url = get_component_url(MyComponent) ``` This way you don't have to mix your app URLs with component URLs. + +!!! info + + If you need to pass query parameters or a fragment to the component URL, you can do so by passing the `query` and `fragment` arguments to [`get_component_url()`](../../../reference/api#django_components.get_component_url): + + ```py + url = get_component_url( + MyComponent, + query={"foo": "bar"}, + fragment="baz", + ) + # /components/ext/view/components/c1ab2c3?foo=bar#baz + ``` diff --git a/src/django_components/extensions/view.py b/src/django_components/extensions/view.py index b8fbea7c..83c5737e 100644 --- a/src/django_components/extensions/view.py +++ b/src/django_components/extensions/view.py @@ -1,5 +1,5 @@ import sys -from typing import TYPE_CHECKING, Any, Optional, Protocol, Type, Union, cast +from typing import TYPE_CHECKING, Any, Dict, Optional, Protocol, Type, Union, cast from weakref import WeakKeyDictionary import django.urls @@ -13,6 +13,7 @@ from django_components.extension import ( URLRoute, extensions, ) +from django_components.util.misc import format_url if TYPE_CHECKING: from django_components.component import Component @@ -32,7 +33,11 @@ def _get_component_route_name(component: Union[Type["Component"], "Component"]) return f"__component_url__{component.class_id}" -def get_component_url(component: Union[Type["Component"], "Component"]) -> str: +def get_component_url( + component: Union[Type["Component"], "Component"], + query: Optional[Dict] = None, + fragment: Optional[str] = None, +) -> str: """ Get the URL for a [`Component`](../api#django_components.Component). @@ -40,6 +45,8 @@ def get_component_url(component: Union[Type["Component"], "Component"]) -> str: Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls). + `get_component_url()` optionally accepts `query` and `fragment` arguments. + **Example:** ```py @@ -50,7 +57,12 @@ def get_component_url(component: Union[Type["Component"], "Component"]) -> str: public = True # Get the URL for the component - url = get_component_url(MyComponent) + url = get_component_url( + MyComponent, + query={"foo": "bar"}, + fragment="baz", + ) + # /components/ext/view/components/c1ab2c3?foo=bar#baz ``` """ view_cls: Optional[Type[ComponentView]] = getattr(component, "View", None) @@ -58,7 +70,8 @@ def get_component_url(component: Union[Type["Component"], "Component"]) -> str: raise RuntimeError("Component URL is not available - Component is not public") route_name = _get_component_route_name(component) - return django.urls.reverse(route_name) + url = django.urls.reverse(route_name) + return format_url(url, query=query, fragment=fragment) class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore diff --git a/src/django_components/util/misc.py b/src/django_components/util/misc.py index ad59b8ab..df160842 100644 --- a/src/django_components/util/misc.py +++ b/src/django_components/util/misc.py @@ -5,7 +5,8 @@ from hashlib import md5 from importlib import import_module from itertools import chain from types import ModuleType -from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Optional, Tuple, Type, TypeVar, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple, Type, TypeVar, Union +from urllib import parse from django_components.constants import UID_LENGTH from django_components.util.nanoid import generate @@ -147,3 +148,32 @@ def to_dict(data: Any) -> dict: return asdict(data) # type: ignore[arg-type] return dict(data) + + +def format_url(url: str, query: Optional[Dict] = None, fragment: Optional[str] = None) -> str: + """ + Given a URL, add to it query parameters and a fragment, returning an updated URL. + + ```py + url = format_url(url="https://example.com", query={"foo": "bar"}, fragment="baz") + # https://example.com?foo=bar#baz + ``` + + `query` and `fragment` are optional, and not applied if `None`. + """ + url_parts = parse.urlsplit(url) + + escaped_fragment = parse.quote(str(fragment)) if fragment is not None else url_parts.fragment + + # NOTE: parse_qsl returns a list of tuples. For simpicity we support only one + # value per key, so we need to unpack the first element. + query_from_url = {key: val[0] for key, val in parse.parse_qsl(url_parts.query)} + all_params = {**query_from_url, **(query or {})} + + encoded_params = parse.urlencode(all_params) + updated_parts = url_parts._replace( + query=encoded_params, + fragment=escaped_fragment, + ) + new_url = parse.urlunsplit(updated_parts) + return new_url diff --git a/tests/test_component.py b/tests/test_component.py index b9b82476..d85c2d4c 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -373,6 +373,16 @@ class TestComponent: with pytest.raises(KeyError): get_component_by_class_id("nonexistent") + def test_component_render_id(self): + class SimpleComponent(Component): + template = "render_id: {{ render_id }}" + + def get_template_data(self, args, kwargs, slots, context): + return {"render_id": self.id} + + rendered = SimpleComponent.render() + assert rendered == "render_id: ca1bc3e" + def test_get_context_data_returns_none(self): class SimpleComponent(Component): template = "Hello" diff --git a/tests/test_component_view.py b/tests/test_component_view.py index ec39ad02..fbbb0f1c 100644 --- a/tests/test_component_view.py +++ b/tests/test_component_view.py @@ -297,6 +297,24 @@ class TestComponentAsView(SimpleTestCase): response.content, ) + def test_component_url(self): + class TestComponent(Component): + template = "Hello" + + class View: + public = True + + # Check if the URL is correctly generated + component_url = get_component_url(TestComponent) + assert component_url == f"/components/ext/view/components/{TestComponent.class_id}/" + + component_url2 = get_component_url(TestComponent, query={"foo": "bar"}, fragment="baz") + assert component_url2 == f"/components/ext/view/components/{TestComponent.class_id}/?foo=bar#baz" + + # Check that the query and fragment are correctly escaped + component_url3 = get_component_url(TestComponent, query={"f'oo": "b ar&ba'z"}, fragment='q u"x') + assert component_url3 == f"/components/ext/view/components/{TestComponent.class_id}/?f%27oo=b+ar%26ba%27z#q%20u%22x" # noqa: E501 + def test_public_url(self): did_call_get = False did_call_post = False