feat: allow to set query and fragment on get_component_url (#1160)

This commit is contained in:
Juro Oravec 2025-05-03 10:29:38 +02:00 committed by GitHub
parent bf7a204e92
commit c69980493d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 102 additions and 5 deletions

View file

@ -275,6 +275,19 @@
- Ignores placeholders and any `<head>` / `<body>` tags. - Ignores placeholders and any `<head>` / `<body>` tags.
- No extra script loaded. - 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 ## v0.139.1
#### Fix #### Fix

View file

@ -147,3 +147,16 @@ url = get_component_url(MyComponent)
``` ```
This way you don't have to mix your app URLs with component URLs. 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
```

View file

@ -1,5 +1,5 @@
import sys 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 from weakref import WeakKeyDictionary
import django.urls import django.urls
@ -13,6 +13,7 @@ from django_components.extension import (
URLRoute, URLRoute,
extensions, extensions,
) )
from django_components.util.misc import format_url
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components.component import Component 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}" 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). 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). Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls).
`get_component_url()` optionally accepts `query` and `fragment` arguments.
**Example:** **Example:**
```py ```py
@ -50,7 +57,12 @@ def get_component_url(component: Union[Type["Component"], "Component"]) -> str:
public = True public = True
# Get the URL for the component # 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) 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") raise RuntimeError("Component URL is not available - Component is not public")
route_name = _get_component_route_name(component) 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 class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore

View file

@ -5,7 +5,8 @@ from hashlib import md5
from importlib import import_module from importlib import import_module
from itertools import chain from itertools import chain
from types import ModuleType 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.constants import UID_LENGTH
from django_components.util.nanoid import generate 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 asdict(data) # type: ignore[arg-type]
return dict(data) 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

View file

@ -373,6 +373,16 @@ class TestComponent:
with pytest.raises(KeyError): with pytest.raises(KeyError):
get_component_by_class_id("nonexistent") 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): def test_get_context_data_returns_none(self):
class SimpleComponent(Component): class SimpleComponent(Component):
template = "Hello" template = "Hello"

View file

@ -297,6 +297,24 @@ class TestComponentAsView(SimpleTestCase):
response.content, 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): def test_public_url(self):
did_call_get = False did_call_get = False
did_call_post = False did_call_post = False